PixiJS
This page builds a shared PixiJS scene from scratch: a schema, a room, and a ticker callback that keeps display objects in step with the players in the room. It assumes you know PixiJS and have a page that already renders a sprite.
Install
npx irtio init # adds @irtio/schema, @irtio/server, @irtio/client and @irtio/testing
npm install
npm install pixi.js 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
PixiJS keeps its own loop. app.ticker runs on the animation frame and calls every callback you
add to it. irtio has no callback for state changes, so you read the room inside the ticker.
| 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 | positioning display objects |
Draw from room.render. A room ticks 20 times a second and the ticker runs at 60, so room.state steps remote sprites three frames at a time. Your own instance comes back from room.render at its immediate local values.
A shared arena
Schema
// irtio/schema.ts
import { defineSchema, entity, f32, str, 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, angle: f32, 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,
angle: 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() {},
}); Stage code
// main.ts
import { joinRoom } from '@irtio/client';
import { Application, Container, Sprite, Texture } from 'pixi.js';
import { HEIGHT, WIDTH, schema } from './irtio/schema.js';
const SPEED = 260; // pixels per second
const app = new Application();
await app.init({ width: WIDTH, height: HEIGHT, background: '#1b2733', antialias: true });
document.body.appendChild(app.canvas);
// One container for the players, so the reconcile pass owns exactly one part of the stage.
const players = new Container();
app.stage.addChild(players);
const room = await joinRoom(schema, { role: 'player', name: 'you' });
const keys = new Set<string>();
addEventListener('keydown', (e) => keys.add(e.key.toLowerCase()));
addEventListener('keyup', (e) => keys.delete(e.key.toLowerCase()));
addEventListener('pagehide', () => room.leave());
const sprites = new Map<string, Sprite>();
function drive(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 = (keys.has('d') ? 1 : 0) - (keys.has('a') ? 1 : 0);
const dy = (keys.has('s') ? 1 : 0) - (keys.has('w') ? 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;
me.angle = Math.atan2(dy, dx);
}
function reconcile(): void {
// room.render: remote players glide between ticks instead of stepping at 20 Hz.
for (const [id, player] of room.render.players) {
let sprite = sprites.get(id);
if (!sprite) {
sprite = new Sprite(Texture.WHITE);
sprite.anchor.set(0.5);
sprite.width = 24;
sprite.height = 24;
sprite.tint = player.color;
sprites.set(id, sprite);
players.addChild(sprite);
}
sprite.position.set(player.x, player.y);
sprite.rotation = player.angle;
}
for (const [id, sprite] of sprites) {
// room.state is the authoritative answer to who is still in the room.
if (room.state.players.has(id)) continue;
players.removeChild(sprite);
sprite.destroy();
sprites.delete(id);
}
}
app.ticker.add(() => {
const dt = Math.min(app.ticker.deltaMS / 1000, 0.1);
drive(dt);
reconcile();
}); 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. PixiJS is y down with the origin at the top left, rotation is in radians
clockwise, and the unit is the pixel before any stage transform. 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 stage does.
A camera changes nothing on the wire. Put your camera on a parent Container and move or
scale that. The schema stays in world units whatever a viewport is doing.
Object graph. A collection is a map from id to record. The stage is a tree of display objects
that live between frames. The bridge is a Map<string, Sprite> and one reconcile pass. Do not put
a display object in the schema. Sync the numbers and build the sprite from them.
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. Set interpolate: false on a
collection whose values should snap, such as a scoreboard. A rotation field lerps like any other
number, so an angle crossing pi takes the long way round and the sprite spins. Store the facing
as sin and cos and call Math.atan2 when you draw it.
Cleanup. Remove and destroy a sprite when its id leaves the collection, as the reconcile pass
does. A texture shared by many sprites is not one sprite’s to free. Call room.leave() on pagehide 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. Pixi’s ticker runs on the animation frame, which is the frame the client
batches owned writes to, with a 50 ms hard cap set by writeIntervalMs on joinRoom. Writing a
field several times inside one ticker callback costs one update on the wire. Call room.flush() only when a single input has to leave now.
Next steps
- Retrofit an existing game, for a canvas game you already have
- Server authority and validation
- Visibility, for scenes with more entities than one player needs
- Client SDK
- Deploying