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:
| file | what it is |
|---|---|
irtio/schema.ts | the shape of the synced data, plus the project id |
irtio/rpc.ts | typed calls in both directions, empty until you need one |
irtio/room.ts | the server rules: who spawns, who owns what, what a write may be |
irtio/room.test.ts | a 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 intoirtio/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
Scenelifecycle andArcade.SpriteAPI. 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.
Related
- Co-op movement, the same shape with a real guardrail on it
- A card game with RPCs, the server-owned shape