Phaser

Advanced Topics

Deterministic Random

Math.random() will cause state desyncs. Always use context.random.

The Problem

// BAD - Will desync
setup: ({ playerIds }) => ({
  enemies: Array.from({ length: 10 }, () => ({
    x: Math.random() * 800,  // Different on each peer!
    y: Math.random() * 600
  }))
})

Host generates different random positions than clients. State desyncs immediately.

The Solution

// GOOD - Same result on all peers
setup: ({ playerIds, random }) => ({
  enemies: Array.from({ length: 10 }, () => ({
    x: random.range(0, 800),  // Deterministic
    y: random.range(0, 600)
  }))
})

context.random is a seeded random generator. Same seed = same sequence on all peers.


Player Join/Leave Handling

Handle players connecting and disconnecting gracefully.

Basic Join/Leave

export const game = defineGame({
  setup: ({ playerIds }) => ({
    players: Object.fromEntries(
      playerIds.map(id => [id, { x: 100, y: 100, score: 0 }])
    )
  }),

  onPlayerJoin: (state, playerId) => {
    // Add new player to state
    state.players[playerId] = {
      x: 100,
      y: 100,
      score: 0
    };
  },

  onPlayerLeave: (state, playerId) => {
    // Remove disconnected player
    delete state.players[playerId];
  }
});

Sprite Cleanup

If you’re manually tracking sprites (not using SpriteManager):

// scene.ts
create() {
  this.adapter = new PhaserAdapter(runtime, this);

  if (!this.adapter.isHost()) {
    this.adapter.onChange((state, prevState) => {
      // Detect removed players
      if (prevState?.players) {
        for (const playerId of Object.keys(prevState.players)) {
          if (!state.players[playerId]) {
            // Player left - destroy their sprite
            const sprite = this.remoteSprites.get(`player-${playerId}`);
            if (sprite) {
              sprite.destroy();
              this.remoteSprites.delete(`player-${playerId}`);
            }
          }
        }
      }
    });
  }
}

With SpriteManager: Cleanup is automatic. When state.players[id] is deleted, onDestroy callback runs.


Common Pitfalls

1. Creating Physics Sprites on Clients

// WRONG
create() {
  const sprite = this.physics.add.sprite(100, 100, 'player'); // On everyone!
}

// RIGHT
create() {
  this.adapter = new PhaserAdapter(runtime, this);

  if (this.adapter.isHost()) {
    const sprite = this.physics.add.sprite(100, 100, 'player');
  }
}

2. Forgetting to Check state._sprites

// WRONG - Will crash on initial render
this.adapter.onChange((state) => {
  for (const [key, data] of Object.entries(state._sprites)) {
    // state._sprites is undefined initially!
  }
});

// RIGHT
this.adapter.onChange((state) => {
  if (!state._sprites) return; // Critical check

  for (const [key, data] of Object.entries(state._sprites)) {
    // Safe
  }
});

3. Forgetting updateInterpolation()

// WRONG - Sprites will teleport instead of moving smoothly
update() {
  // Nothing
}

// RIGHT
update() {
  if (!this.adapter.isHost()) {
    this.adapter.updateInterpolation();
  }
}

4. Using Math.random()

// WRONG - State desync
actions: {
  spawn: {
    apply(state) {
      state.enemy.x = Math.random() * 800; // Different on each peer
    }
  }
}

// RIGHT
actions: {
  spawn: {
    apply(state, context) {
      state.enemy.x = context.random.range(0, 800); // Same on all peers
    }
  }
}

5. Modifying State Outside Actions

// WRONG - Won't sync to other players
update() {
  const state = this.adapter.getState();
  state.players[this.playerId].score++; // Direct mutation
}

// RIGHT
update() {
  runtime.submitAction('incrementScore', {});
}