Retrofit an existing game

Start with the state players need to share, then connect it to your existing input and render loops. The example below adapts a local cursor canvas; engine-specific examples follow.

Find the shared surface

Write down the state two players must agree on. For the cursors toy it is a position, a name and a colour, one set per person in the room.

Everything else stays local and untouched: the camera, particle effects, screen shake, tweening, input handling, the HUD, the draw code, the level geometry your game already loads. If your retrofit touches those, it is doing more than a retrofit.

1. Scaffold

npx irtio init

It asks one question, whether anything in your game moves without a player doing something, and writes five files:

filewhat it is
irtio/schema.tsthe shape of the synced data, plus the project id
irtio/rpc.tstyped calls in both directions, empty until you need one
irtio/room.tsthe server rules: who spawns, who owns what, what a write may be
irtio/room.test.tsa smoke test that runs the whole room in process
irtio.json{ "project": "p_…" }

The project id is public. It is domain-locked rather than secret, so it is safe in client code and safe in a git repo.

2. Describe the shared surface

// irtio/schema.ts
import { defineSchema, entity, f32, str, u8 } from '@irtio/schema';

export const schema = defineSchema(
  {
    // One instance per connected client. `entity` means "keyed collection of instances,
    // each with an owner".
    players: entity({ x: f32, y: f32, name: str(24), color: u8 }),
  },
  {
    project: 'p_c0ffee1234abcd56', // your project id, from irtio.json
    roles: ['player'] as const,
  },
);

Give the entity the fields your local player object had. The types are budgets, not hints. f32, u8 and str(24) are what turn an update into a handful of bytes instead of a JSON blob, and a value outside the declared range is rejected rather than quietly rounded.

Both sides import this file. The room runs on it and your game draws from it.

3. Spawn on join, remove on leave

// irtio/room.ts
import { defineRoom } from '@irtio/server';

import { schema } from './schema.js';

const WIDTH = 800;
const HEIGHT = 500;

export default defineRoom(schema, {
  mode: 'tick',
  tickRate: 20,

  onJoin(state, ctx) {
    if (ctx.reconnecting) return;
    state.players.add(
      ctx.clientId,
      { x: 0, y: 0, name: ctx.name || 'anon', color: (ctx.tick * 37) % 256 },
      { owner: ctx.clientId },
    );
  },

  onLeave(state, ctx) {
    state.players.remove(ctx.clientId);
  },

  // Owner writes pass through here before they are accepted. Return `next` to accept it,
  // `prev` to reject it, or a clamped object.
  validate: {
    players(prev, next) {
      // Refuse what could never be a real value, lock the fields the room assigned at join,
      // and clamp the rest onto the canvas.
      if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;
      if (next.name !== prev.name || next.color !== prev.color) return prev;
      return {
        ...next,
        x: Math.min(WIDTH, Math.max(0, next.x)),
        y: Math.min(HEIGHT, Math.max(0, next.y)),
      };
    },
  },

  // Tick mode requires a `tick`, even an empty one. Nothing in this game moves on its own.
  tick() {},
});

There is no limit here on how far a cursor may move between two writes. A pointer legitimately teleports, so a speed limit on a cursor refuses honest input and protects nothing: a player gains nothing by putting their cursor somewhere. Save the step limit for a character with a movement speed your game defines. Server authority covers the things that should not be client-owned at all.

{ owner: ctx.clientId } is the line the rest of the guide depends on. It says this instance belongs to that client, which is what lets the client write it directly. See Ownership for the full model.

This file is server-only. irtio dev and irtio deploy bundle it, and it never reaches the browser, so nothing you import here ends up in your game’s bundle.

4. Join the room

+import { joinRoom } from '@irtio/client';
+
+import { schema } from './irtio/schema.js';
+
+const room = await joinRoom(schema, { name: 'you' });

With no ?room= in the page URL, joinRoom creates a room and appends ?room=CODE to the address bar. With one, it joins that room. You write no URL, no key and no environment variable.

5. Write your entity instead of the local object

-const player = { x: canvas.width / 2, y: canvas.height / 2, name: 'you', color: 200 };
-
 canvas.addEventListener('pointermove', (event) => {
   const bounds = canvas.getBoundingClientRect();
+  const player = room.state.players[room.me];
+  if (!player) return;
   player.x = event.clientX - bounds.left;
   player.y = event.clientY - bounds.top;
 });

room.me is the client id the server knows you by, the same ctx.clientId that onJoin used, so room.state.players[room.me] is the instance you own. Write it exactly like the local object it replaced.

Four things change with it.

  • Spawn values come from onJoin, not from the client, so your starting position moved into irtio/room.ts.
  • Your entity does not exist for the first frame or two after the page loads, which is what the if (!player) return; is for. room.state.players.get(room.me) reads the same as the index form if you prefer it.
  • Assignments are batched, not sent one by one. The client collects everything you write and sends one update per flush window, aligned to the animation frame with a 50 ms hard cap. Call room.flush() if you need a write on the wire right now.
  • Everything you do not own is frozen. Writing to another player’s instance is a compile error, and at runtime it is a no-op that warns once and names the owner.

6. Draw the collection instead of the object

-  const entries = [['me', player]];
+  const entries = [...room.state.players];
   for (const [, p] of entries) draw(p);

The collection iterates [id, value] pairs and always holds exactly the players currently in the room. Joins and leaves maintain it for you. Your draw function never knew where its argument came from, so it does not change.

Smooth remote players are one more substitution on the same line:

-  const entries = [...room.state.players];
+  const entries = [...room.render.players];

room.render has the same shapes as room.state, but it reads non-owned entities a beat behind arrival (two tick intervals by default, floored at 50 ms) and interpolates their numeric fields between the updates either side. Remote cursors glide instead of stepping at the tick rate. Your own entity still comes back at its immediate local values, and room.state stays the authoritative read path for game logic and tests.

To feel the difference, the multiplayer copy of the cursors canvas example accepts ?latency=150 in its page URL and holds every frame 75 ms in each direction.

7. Add the lobby

One tag and one call, and you have a share UI.

<irt-lobby></irt-lobby>
+import '@irtio/lobby';
+
+document.querySelector('irt-lobby')?.attach(room);

It shows the room code (click to copy), the share link, a QR code of that link, and a connection dot that knows about waking and reconnecting. See Phones as controllers for what else it does.

Verify it

npx irtio dev                # bundles irtio/room.ts and runs it locally
npx irtio simulate --bots 5 --seconds 10
npx vitest run irtio/room.test.ts

Then open the page, look at the address bar for ?room=CODE, and paste that URL into a second tab. Both cursors appear in both tabs. Simulated players covers the bot run in more detail, and Deploying covers getting it off your laptop.

What did not change

The draw function, the canvas setup, the roster rendering, the animation loop, the HTML and the pointer maths. The whole diff is an import, a join, an entity lookup, and one changed iteration.

When to move to server authority

This guide is the owned-write shape: a player moves their own thing and the server validates it. It suits cursors, drag positions, cameras, co-op characters, anything where a player is reporting a fact about themselves.

When the game decides something rather than a player, that state should be serverOwned and clients should call RPCs instead of writing. Scores, deals, turn order, hit detection, anything a player could gain by lying about. That change is additive: the collections you already made owned stay owned. See Server authority and RPCs.

The same shape in Three.js

The steps above are the shape. This section applies it to a Three.js loop. Every “after” line below is copied from the Rapier arena example, which builds, typechecks and runs its own browser test, and a test fails if a line here stops matching it.

Three.js changes nothing about the model. Your meshes stay yours, your camera stays yours, your materials and lights and post-processing stay yours. What changes is where the numbers you feed into mesh.position come from.

What is different from the canvas case

Two things.

A scene graph is not a draw call. The canvas loop redraws from scratch every frame, so iterating a collection is the whole change. Three.js keeps meshes alive between frames, so joins and leaves have to add and remove meshes. That is a map from id to mesh and a pass to reconcile it, which is code any multi-entity Three.js scene needs anyway.

3D usually means physics, and physics means the server owns the positions. The client writes an intent (a steering axis), the server steps the world, and the client predicts locally with the same world-builder module. That is the Rapier path the arena example shows.

1. Scaffold, with physics

npx irtio init --physics

That writes the same files as a plain init plus irtio/world.ts, the shared world-builder both sides import. Prediction works only if world.ts is pure over synced inputs: no Math.random(), no clock, no module state.

2. The schema declares which fields the simulation owns

// irtio/schema.ts
players: entity(
  { x: f32, y: f32, z: f32, vx: f32, vy: f32, vz: f32, ax: f32, az: f32, name: str(24) },
  {
    physics: {
      body: { x: 'x', y: 'y', z: 'z', vx: 'vx', vy: 'vy', vz: 'vz' },
      intents: ['ax', 'az'],
    },
  },
),

body fields are simulation output and read-only everywhere. intents are the only fields a client may write. Map the velocity channels as well as the positions: a correction can only rebase what the schema carries, and a predicted body drifts without them.

3. Join, and hand the client the same world

+import { joinRoom } from '@irtio/client';
+import rapier3d from '@irtio/client/rapier3d';
+
+import { schema } from './irtio/schema.js';
+import * as world from './irtio/world.js';
+
+const room = await joinRoom(schema, {
+  role: 'player',
+  physics: {
+    module: rapier3d,
+    gravity: world.gravity,
+    setup: world.setup,
+    bodies: world.bodies,
+    intents: world.intents,
+  },
+});

Passing physics is what turns on client-side prediction. Without it everything still works; the local ball just waits a round trip before it moves.

4. Input writes an intent instead of moving a mesh

 function applyIntent(): void {
-  ball.position.x += ax * SPEED;
-  ball.position.z += az * SPEED;
+  const me = room.state.players[room.me];
+  if (!me) return;
+  me.ax = Math.max(-1, Math.min(1, ax));
+  me.az = Math.max(-1, Math.min(1, az));
 }

Clamp on the client as well as in the room. The room clamps the same values in validate, so a client that skips the clamp is corrected every tick instead of agreeing with the server.

5. The render loop reconciles meshes against room.render

 function frame(): void {
-  ball.position.set(local.x, local.y, local.z);
+  const players = new Map<string, { x: number; y: number; z: number }>();
+  for (const [id, p] of room.render.players) players.set(id, p);
+
+  syncPlayers(handles, world.BALL_RADIUS, players, room.me);
+  followCamera(handles, players.get(room.me));

   handles.renderer.render(handles.scene, handles.camera);
   requestAnimationFrame(frame);
 }

syncPlayers is ordinary Three.js and lives in your own code, not in irtio. In the arena example it is about twenty lines: for each id in the map, find or create a mesh, set its position; for each mesh with no id left, remove and dispose it. Write it once and every collection uses it.

Read from room.render, not room.state. render gives your own predicted body at its immediate local values and everyone else’s a beat behind arrival, interpolated between the updates either side, which is what stops remote balls stepping at the tick rate. room.state stays the authoritative read path for game logic and tests.

What did not change in the Three.js case

The scene, the camera rig, the lights, the materials, the resize handler, the HUD, the loader and the post-processing. The arena example keeps all of that in one render module of plain Three.js that imports nothing from irtio.

Verify the Three.js retrofit

npx irtio dev
npx irtio simulate --bots 8 --seconds 30

A physics room has one number worth watching that a canvas room does not: misprediction. The run reports mean and maximum divergence between what the client predicted and what the server decided, plus snap counts. On localhost the arena example reads as a few units of mean misprediction and zero snaps. A snap means a correction landed outside the resimulation window. A room that snaps regularly has a world-builder that is not pure, or intents the client is not clamping. Physics covers the full prediction model.

The same shape in Phaser

Read this first. There is no Phaser example to copy from, so unlike the canvas and Three.js recipes above, the diff below has not been compiled or run against a real Phaser build. The irtio half of it is the same API those recipes 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 suits 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.

That decides 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 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 in the Phaser case

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 the Phaser retrofit

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

Run --cheat against a Phaser retrofit. Writing your sprite’s position straight to the wire is 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.