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 Complete the quickstart to create your room, then use the client code below with your engine.
The loop
PixiJS keeps its own loop. app.ticker runs on the animation frame and calls every callback you add
to it. Read the room inside the ticker for anything drawn every frame. Use room.onAdd, room.onRemove and room.onChange for a record
appearing, leaving, or changing, such as creating and destroying a sprite.
| 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.
Connect your scene
Start from the quickstart. Replace its schema with the one below, keeping its project option. Use the room definition below to create records for this schema.
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', // your project id, from irtio.json
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() {},
}); Client example
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.
Related
- Retrofit an existing game, for a canvas game you already have
- Server authority and validation