Phaser
Phaser Physics & Input
This track is Phaser-specific. For engine-agnostic patterns, start with Physics & Collisions (Engine-Agnostic).
Input Handling Pattern
Inputs go through state, not directly to sprites. This keeps host and clients in sync.
The Flow
- Player presses key →
submitAction('move', { x, y }) - Action stores input in
state.inputs[playerId] - Host reads inputs from state → applies to physics sprites
- Clients receive updated state → sprites update automatically
Implementation
// game.ts
import { createInputAction } from '@martini-kit/core';
export const game = defineGame({
setup: ({ playerIds }) => ({
players: createPlayers(playerIds),
inputs: {} as Record<string, { x?: number; y?: number }>
}),
actions: {
move: createInputAction('inputs') // Stores input in state
}
}); // scene.ts
create() {
this.adapter = new PhaserAdapter(runtime, this);
// Setup input manager
const inputManager = this.adapter.createInputManager();
inputManager.bindKeys({
'W': { action: 'move', input: { y: -1 }, mode: 'continuous' },
'S': { action: 'move', input: { y: 1 }, mode: 'continuous' },
'A': { action: 'move', input: { x: -1 }, mode: 'continuous' },
'D': { action: 'move', input: { x: 1 }, mode: 'continuous' }
});
// HOST: Apply inputs to physics
if (this.adapter.isHost()) {
const physicsManager = this.adapter.createPhysicsManager({
stateKey: 'inputs',
behaviors: [
{
type: 'top-down',
speed: 200,
applyTo: (playerId) => this.playerSprites.get(playerId)!
}
]
});
}
} Why Store Inputs in State?
Inputs must be in state so the host can read them. The host runs all physics calculations, so it needs to know what every player is doing. Storing inputs in state automatically syncs them from clients to host.
Basic Physics Setup
Host-Only Physics
import Phaser from 'phaser';
import { PhaserAdapter } from '@martini-kit/phaser';
export class GameScene extends Phaser.Scene {
private adapter!: PhaserAdapter;
create() {
this.adapter = new PhaserAdapter(runtime, this);
if (this.adapter.isHost()) {
// ONLY host creates physics sprites
const player = this.physics.add.sprite(100, 100, 'player');
player.setBounce(0.2);
player.setCollideWorldBounds(true);
// Track sprite for automatic syncing
this.adapter.trackSprite(player, `player-${playerId}`);
} else {
// Clients create VISUAL-ONLY sprites
const player = this.add.sprite(100, 100, 'player');
this.adapter.trackSprite(player, `player-${playerId}`);
}
// Subscribe to state changes (both host and clients)
this.adapter.onChange((state) => {
this.updateSprites(state);
});
}
} Syncing Physics Properties
When using trackSprite(), these properties are automatically synced:
- Position:
x,y - Rotation:
rotationorangle - Velocity:
velocityX,velocityY(if you enable velocity syncing) - Scale:
scaleX,scaleY - Visibility:
visible
// Host: Physics moves sprite automatically
if (this.adapter.isHost()) {
const sprite = this.physics.add.sprite(100, 100, 'player');
this.adapter.trackSprite(sprite, 'player-1', {
syncVelocity: true // Also sync velocity for smoother client interpolation
});
// Apply physics forces
sprite.setVelocityX(200);
// Position updates automatically, synced by martini-kit
} Collision Handling
Collisions should ONLY be detected on the host. Use CollisionManager for declarative collision rules, or standard Phaser physics with state updates.
Using CollisionManager (Recommended)
create() {
this.adapter = new PhaserAdapter(runtime, this);
if (this.adapter.isHost()) {
// Create collision manager
const collisionManager = this.adapter.createCollisionManager({
rules: [
{
between: ['player', 'enemy'],
onCollide: (player, enemy) => {
// Submit action to update state
runtime.submitAction('damage', { amount: 10 });
}
}
]
});
}
} Standard Phaser Collisions
When collisions affect game state, use actions:
create() {
if (this.adapter.isHost()) {
const players = this.physics.add.group();
const enemies = this.physics.add.group();
// Detect overlap
this.physics.add.overlap(
players,
enemies,
(playerSprite, enemySprite) => {
// Get IDs from sprite data
const playerId = playerSprite.getData('id');
const enemyId = enemySprite.getData('id');
// Submit action to update state
runtime.submitAction('playerHitEnemy', {
playerId,
enemyId,
damage: 10
});
}
);
}
} Collision Actions Pattern
// In game definition
actions: {
playerHitEnemy: {
apply(state, context, input: { playerId: string; enemyId: string; damage: number }) {
const player = state.players[input.playerId];
const enemy = state.enemies[input.enemyId];
if (!player || !enemy) return;
// Update health
player.health -= input.damage;
// Check for death
if (player.health <= 0) {
delete state.players[input.playerId];
}
// Emit event for client-side effects
context.emit('playerHit', {
playerId: input.playerId,
damage: input.damage
});
}
}
} Client-Side Visual Effects
Clients listen for collision events to show effects:
create() {
// Listen for collision events
runtime.onEvent('playerHit', (playerId, payload) => {
if (playerId === this.adapter.getMyPlayerId()) {
// Show damage effect
this.cameras.main.shake(100, 0.01);
this.showDamageNumber(payload.damage);
}
});
}
private showDamageNumber(damage: number) {
const player = this.playerSprites.get(this.adapter.getMyPlayerId());
if (!player) return;
const text = this.add.text(player.x, player.y - 40, `-${damage}`, {
fontSize: '24px',
color: '#ff0000',
fontStyle: 'bold'
});
this.tweens.add({
targets: text,
y: text.y - 50,
alpha: 0,
duration: 1000,
onComplete: () => text.destroy()
});
} Common Pitfalls
Pitfall 1: Running Physics on Clients
// WRONG ❌
update() {
const player = this.playerSprites.get(this.adapter.getMyPlayerId());
if (player) {
player.setVelocityX(200); // Physics on client!
}
}
// CORRECT ✅
update() {
if (this.adapter.isHost()) {
const player = this.playerSprites.get(this.adapter.getMyPlayerId());
if (player) {
player.setVelocityX(200); // Physics only on host
}
}
} Pitfall 2: Not Syncing Collision Results
// WRONG ❌ - Collision detected but state not updated
this.physics.add.overlap(players, coins, (player, coin) => {
coin.destroy(); // Only destroys on host's screen!
});
// CORRECT ✅ - Update state via action
this.physics.add.overlap(players, coins, (player, coin) => {
const playerId = player.getData('id');
const coinId = coin.getData('id');
runtime.submitAction('collectCoin', { playerId, coinId });
// Action removes coin from state, synced to all clients
}); Pitfall 3: Forgetting to Check isHost()
// WRONG ❌
create() {
this.physics.add.sprite(100, 100, 'player');
// Creates physics on both host and clients!
}
// CORRECT ✅
create() {
if (this.adapter.isHost()) {
this.physics.add.sprite(100, 100, 'player');
} else {
this.add.sprite(100, 100, 'player'); // Visual only
}
} See Also
- Movement Patterns - Player movement guides
- Phaser Adapter API - PhaserAdapter reference
- State Management - Understanding state sync
- Actions - Writing action handlers