Retrofit a Phaser game

Read this first. There is no Phaser example in the irtio repository, so unlike the canvas and Three.js recipes, the diff below has not been compiled or run against a real Phaser build. The irtio half of it is the same API those two pages use and is covered by tests; the Phaser half is written against Phaser 3’s documented Scene lifecycle and Arcade.Sprite API. Treat it as a shape to follow, not a snippet to paste.

Phaser is a good fit for the owned-write retrofit, because a Phaser scene already separates the sprite from the thing the sprite represents. The retrofit puts irtio in between.

The one Phaser-specific decision

Do not sync a Phaser.GameObjects.Sprite. Sync the numbers. A sprite carries a texture, a body, a tween state and a scene reference, none of which belong on a wire. The schema holds the position, the facing and whatever else the other player must agree with, and the sprite reads from it in update.

The second consequence is where your input goes. Phaser’s Arcade physics moves sprites by setting velocity, and if the sprite is the source of truth, two clients disagree the moment either one lags. So the local sprite stays the thing you move, and its position is written to your entity once per frame; the room’s validate is what decides whether that write is plausible.

1. Scaffold

npx irtio init

Answer the one question with the mode your game is in. A Phaser game with a update() that moves things on its own is tick mode; a turn-based board is event mode.

2. The schema is the sprite’s numbers, not the sprite

// irtio/schema.ts
players: entity({ x: f32, y: f32, vx: f32, vy: f32, facing: u8, name: str(24) }),

3. The room validates a move the way your game already bounds one

// irtio/room.ts
const WORLD_WIDTH = 1600;
const WORLD_HEIGHT = 900;
/** Arcade physics top speed times the longest plausible gap between two writes. */
const MAX_STEP = 400 * 0.25;

validate: {
  players(prev, next) {
    if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
    return {
      ...next,
      x: Math.min(WORLD_WIDTH, Math.max(0, next.x)),
      y: Math.min(WORLD_HEIGHT, Math.max(0, next.y)),
    };
  },
},

4. Join in create, and write your own sprite in update

+import { joinRoom } from '@irtio/client';
+
+import { schema } from './irtio/schema.js';
+
 class Play extends Phaser.Scene {
+  private room!: Awaited<ReturnType<typeof joinRoom<typeof schema>>>;
+  private others = new Map<string, Phaser.GameObjects.Sprite>();
+
-  create() {
+  async create() {
+    this.room = await joinRoom(schema, { name: 'you' });
     this.player = this.physics.add.sprite(400, 300, 'hero');
     this.cursors = this.input.keyboard.createCursorKeys();
   }

   update() {
     this.player.setVelocityX(this.cursors.left.isDown ? -400 : this.cursors.right.isDown ? 400 : 0);
+
+    const me = this.room.state.players[this.room.me];
+    if (!me) return;
+    me.x = this.player.x;
+    me.y = this.player.y;
+    me.facing = this.player.flipX ? 1 : 0;
   }
 }

create returning a promise is supported by Phaser’s scene lifecycle, and the if (!me) return; covers the frames between the scene starting and the entity existing.

5. Reconcile the other players’ sprites

   update() {
     // ...your own movement, as above
+
+    for (const [id, p] of this.room.render.players) {
+      if (id === this.room.me) continue;
+      let sprite = this.others.get(id);
+      if (!sprite) {
+        sprite = this.add.sprite(p.x, p.y, 'hero');
+        this.others.set(id, sprite);
+      }
+      sprite.setPosition(p.x, p.y);
+      sprite.setFlipX(p.facing === 1);
+    }
+    for (const [id, sprite] of this.others) {
+      if (!this.room.state.players.has(id)) {
+        sprite.destroy();
+        this.others.delete(id);
+      }
+    }
   }

That reconcile pass is the whole Phaser-specific part of the retrofit, and it is the same pass the Three.js recipe writes against meshes. Read remote players from room.render so they interpolate between updates instead of stepping at the tick rate; read room.state when you need the authoritative value, as the removal check above does.

Remote sprites are display objects, not physics bodies. Adding them with this.add.sprite rather than this.physics.add.sprite keeps Arcade physics from fighting the positions the server sent.

What did not change

The scene list, the tilemap, the animations, the camera, the sound, the particle emitters, the Arcade collider for your own player. The retrofit touches create, update, and nothing else.

Verify it

npx irtio dev
npx irtio simulate --bots 5 --seconds 10 --cheat

--cheat is the one worth running against a Phaser retrofit, because writing your sprite’s position straight to the wire is exactly the shape a cheating client abuses. If the run prints HOLE bot 0 cheated and drew 0 corrections, your MAX_STEP is too generous or missing.

Next steps