Phaser
This page builds a shared Phaser scene from scratch: a schema, a room, and a scene whose update reads the room. It assumes you know Phaser 3 and have a game config that already boots.
To add irtio to a Phaser game that already exists, read Retrofit a Phaser game. That page is a diff.
Install
npx irtio init # adds @irtio/schema, @irtio/server, @irtio/client and @irtio/testing
npm install
npm install phaser irtio init writes irtio/schema.ts, irtio/room.ts, irtio/room.test.ts and irtio.json.
The project id in them is public and domain locked, so it is safe in client code.
The loop
Phaser keeps its own game loop and calls update(time, delta) on the active scene every frame.
irtio has no callback that fires when state changes. You read the room inside update.
| Read path | What it gives you | Where to use it |
|---|---|---|
room.state | what the server last said, plus your own local writes | game logic, your own input, tests |
room.render | the same shape, with non-owned instances a beat behind arrival and their numeric fields interpolated | drawing other players |
Draw other players from room.render. A room ticks 20 times a second and Phaser runs at 60, so room.state steps remote sprites three frames at a time. Read room.state for your own sprite
and for the removal check.
Do not sync a Phaser.GameObjects.Sprite. A sprite carries a texture, a body, a tween state and
a scene reference, none of which belong on a wire. Sync the numbers and build the sprite from
them.
A shared arena
Schema
// irtio/schema.ts
import { defineSchema, entity, f32, str, u8, u32 } from '@irtio/schema';
export const WIDTH = 800;
export const HEIGHT = 600;
export const schema = defineSchema(
{
// One instance per client, owned by the client that joined as it.
players: entity({ x: f32, y: f32, facing: u8, color: u32, name: str(24) }),
},
{
project: 'p_c0ffee1234abcd56', // written by `irtio init`
roles: ['player'] as const,
},
); Room file
// irtio/room.ts
import { defineRoom } from '@irtio/server';
import { HEIGHT, WIDTH, schema } from './schema.js';
const COLORS = [0x4dabf7, 0xffd166, 0x9ae66e, 0xff6b6b, 0xc084fc];
/** Top speed times the longest plausible gap between two writes. */
const MAX_STEP = 260 * 0.25;
export default defineRoom(schema, {
mode: 'tick',
tickRate: 20,
maxClients: 16,
onJoin(state, ctx) {
if (ctx.reconnecting) return;
state.players.add(
ctx.clientId,
{
x: Math.random() * WIDTH,
y: Math.random() * HEIGHT,
facing: 0,
color: COLORS[state.players.size % COLORS.length],
name: ctx.name || 'anon',
},
{ owner: ctx.clientId }, // this is what lets the client write the instance
);
},
onLeave(state, ctx) {
state.players.remove(ctx.clientId);
},
validate: {
players(prev, next) {
if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return prev;
// The room assigned these at join, so a client write to them is a lie.
if (next.name !== prev.name || next.color !== prev.color) return prev;
if (Math.hypot(next.x - prev.x, next.y - prev.y) > MAX_STEP) return prev;
return {
...next,
x: Math.min(WIDTH, Math.max(0, next.x)),
y: Math.min(HEIGHT, Math.max(0, next.y)),
};
},
},
// Tick mode needs a tick, even an empty one. Nothing here moves on its own.
tick() {},
}); Scene code
// main.ts
import { joinRoom } from '@irtio/client';
import type { Room } from '@irtio/client';
import Phaser from 'phaser';
import { HEIGHT, WIDTH, schema } from './irtio/schema.js';
const SPEED = 260; // pixels per second
class Play extends Phaser.Scene {
private room?: Room<typeof schema>;
private cursors!: Phaser.Types.Input.Keyboard.CursorKeys;
private shapes = new Map<string, Phaser.GameObjects.Rectangle>();
constructor() {
super('play');
}
create(): void {
this.cursors = this.input.keyboard!.createCursorKeys();
// Joining is async. update() guards on this.room until it lands.
joinRoom(schema, { role: 'player', name: 'you' }).then((room) => {
this.room = room;
});
this.events.once('shutdown', () => this.room?.leave());
}
update(_time: number, delta: number): void {
const room = this.room;
if (!room) return;
this.drive(room, delta / 1000);
this.reconcile(room);
}
private drive(room: Room<typeof schema>, dt: number): void {
// room.state, not room.render: this is the instance you write.
const me = room.state.players[room.me];
if (!me) return; // the instance arrives a frame or two after the join resolves
const dx = (this.cursors.right.isDown ? 1 : 0) - (this.cursors.left.isDown ? 1 : 0);
const dy = (this.cursors.down.isDown ? 1 : 0) - (this.cursors.up.isDown ? 1 : 0);
if (dx === 0 && dy === 0) return;
const len = Math.hypot(dx, dy);
me.x += (dx / len) * SPEED * dt; // owned write, batched into one update per frame
me.y += (dy / len) * SPEED * dt;
if (dx !== 0) me.facing = dx > 0 ? 1 : 0;
}
private reconcile(room: Room<typeof schema>): void {
// room.render: remote players glide between ticks instead of stepping at 20 Hz.
for (const [id, player] of room.render.players) {
let shape = this.shapes.get(id);
if (!shape) {
shape = this.add.rectangle(player.x, player.y, 24, 24, player.color);
this.shapes.set(id, shape);
}
shape.setPosition(player.x, player.y);
shape.setScale(player.facing === 1 ? 1 : -1, 1);
}
for (const [id, shape] of this.shapes) {
// room.state is the authoritative answer to who is still in the room.
if (room.state.players.has(id)) continue;
shape.destroy();
this.shapes.delete(id);
}
}
}
new Phaser.Game({
type: Phaser.AUTO,
width: WIDTH,
height: HEIGHT,
backgroundColor: '#1b2733',
scene: Play,
}); Run npx irtio dev, open the page, copy the URL once it carries ?room=CODE, and paste it into
a second tab.
Engine-specific notes
Coordinate system. Phaser is y down with the origin at the top left, and its unit is the
pixel. Keep WIDTH and HEIGHT in the schema file and import them on both sides, so the clamp
in irtio/room.ts uses the same numbers your game config does.
Object graph. A collection is a map from id to record. A scene holds game objects that live
between frames. The bridge is a Map<string, GameObject> and one reconcile pass in update. Add
remote players with this.add.*, not this.physics.add.*: a remote player is a display object,
and Arcade physics would fight the positions the room sent.
Arcade physics for your own sprite. Keep it. Let Arcade move your sprite, then copy sprite.x and sprite.y onto your instance once per frame. Derive MAX_STEP in irtio/room.ts from your own top speed, because that is what decides whether the report is
plausible.
Interpolation. room.render interpolates numeric fields between the updates either side of now - interpDelayMs, which defaults to max(50, 2 x tick interval) and is a joinRoom option.
Do not tween on top of it. Two layers of smoothing read as lag.
Cleanup. Destroy a game object when its id leaves the collection, as the reconcile pass does.
Call room.leave() on the scene’s shutdown event so the room sees the departure without
waiting out the reconnection grace window, 30 seconds by default and set with reconnectGraceMs in irtio/room.ts.
Write batching. The write window is one animation frame with a 50 ms hard cap, writeIntervalMs on joinRoom. Writing a field several times inside one update costs one
update on the wire. Call room.flush() only when a single input has to leave now.
Next steps
- Retrofit a Phaser game, the same model on a scene you have
- Server authority and validation
- Ownership
- Client SDK
- Simulated players