Integrations
PixiJS
Draw an IRT room with PixiJS 8, with predicted movement, interpolated players, owned aim, calls and teardown.
This page builds the runners game from
Engines and frameworks with PixiJS 8. It
imports shared.ts from that page; read its
Connecting and
Teardown sections first.
PixiJS is a renderer, not a framework, so the arena is a function: it
takes an initialized Application and a room ID, adds a layer to the
stage, and returns a function that removes everything it added.
// arena.ts
import { CloseCode, RoomClient } from "@irt/client";
import { type Application, Container, type FederatedPointerEvent, Graphics } from "pixi.js";
import { type Input, Runner, schema, stepper } from "./shared.js";
const router = location.hostname.endsWith(".irt-serve.com") ? "wss://eu.irt.io" : location.origin;
// Pixels per world unit.
const PX = 32;
interface Sprite {
body: Container;
nose: Graphics;
}
export function startArena(app: Application, roomId: string, show: (status: string) => void) {
const room = new RoomClient({ router, project: "my-game", room: roomId, schema });
const layer = app.stage.addChild(new Container());
const sprites = new Map<number, Sprite>();
show("connecting");
const offs = [
room.on("waking", () => show("waking the room")),
room.on("disconnected", () => show("reconnecting")),
room.on("restored", () => show("")),
room.on("closed", (e) => {
if (e.code === CloseCode.SchemaMismatch) location.reload();
else show(e.message);
}),
room.entities.onEnter(Runner, (r) => {
const body = layer.addChild(new Container());
body.addChild(new Graphics().circle(0, 0, 12).fill(r.owner === room.player ? 0x4cc9f0 : 0xf72585));
const nose = body.addChild(new Graphics().rect(0, -2, 18, 4).fill(0xffffff));
nose.rotation = r.angle;
body.position.set(r.x * PX, r.y * PX);
sprites.set(r.id, { body, nose });
}),
room.entities.onChange(Runner, (r, changed) => {
const sprite = sprites.get(r.id);
if (sprite && changed.includes("angle")) sprite.nose.rotation = r.angle;
}),
room.entities.onLeave(Runner, (r) => {
sprites.get(r.id)?.body.destroy({ children: true });
sprites.delete(r.id);
}),
];
const keys = new Set<string>();
const down = (e: KeyboardEvent) => keys.add(e.key);
const up = (e: KeyboardEvent) => keys.delete(e.key);
addEventListener("keydown", down);
addEventListener("keyup", up);
const me = room.predict(stepper, {
find: (r) => [...r.entities.all(Runner)].find((e) => e.owner === r.player),
sample: (): Input => ({
dx: (keys.has("ArrowRight") ? 1 : 0) - (keys.has("ArrowLeft") ? 1 : 0),
dy: (keys.has("ArrowDown") ? 1 : 0) - (keys.has("ArrowUp") ? 1 : 0),
}),
});
const others = room.interpolate(Runner, ["x", "y"]);
app.stage.eventMode = "static";
app.stage.hitArea = app.screen;
const aim = (e: FederatedPointerEvent) => {
const at = me.render(["x", "y"]);
for (const r of room.entities.mine(Runner)) {
r.angle = Math.atan2(e.global.y - at.y * PX, e.global.x - at.x * PX);
const sprite = sprites.get(r.id);
if (sprite) sprite.nose.rotation = r.angle;
}
};
app.stage.on("pointermove", aim);
const frame = () => {
const local = me.render(["x", "y"]);
for (const r of room.entities.all(Runner)) {
const at = r.owner === room.player ? local : others.get(r);
if (!Number.isNaN(at.x)) sprites.get(r.id)?.body.position.set(at.x * PX, at.y * PX);
}
};
app.ticker.add(frame);
const stop = () => {
for (const off of offs) off();
app.ticker.remove(frame);
app.stage.off("pointermove", aim);
removeEventListener("keydown", down);
removeEventListener("keyup", up);
me.stop();
room.leave();
layer.destroy({ children: true });
sprites.clear();
};
return { room, stop };
}
And the page that runs it:
// main.ts
import { IrtError } from "@irt/client";
import { Application } from "pixi.js";
import { startArena } from "./arena.js";
import { Wallet } from "./shared.js";
const app = new Application();
await app.init({ resizeTo: window, background: "#101418", antialias: true });
document.body.appendChild(app.canvas);
const status = document.querySelector("#status")!;
const coins = document.querySelector("#coins")!;
const buy = document.querySelector<HTMLButtonElement>("#buy")!;
const arena = startArena(app, new URLSearchParams(location.search).get("room") ?? "arena", (s) => (status.textContent = s));
document.querySelector("#leave")!.addEventListener("click", arena.stop);
arena.room.entities.onChange(Wallet, (w) => (coins.textContent = `${w.coins} coins`));
buy.addEventListener("click", async () => {
try {
const reply = await arena.room.call("buy", { item: "dash" });
coins.textContent = `${reply.coins} coins`;
} catch (e) {
if (e instanceof IrtError) status.textContent = e.message;
else throw e;
}
});
arena.room.ready.then(
() => {
buy.disabled = false;
coins.textContent = `${arena.room.private!.coins} coins`;
},
// The closed listener in startArena has reported it.
() => {},
);
How it fits together:
- Listeners before
ready.startArenareturns as soon as the client is constructed, with every listener in place, so the runners already in the room enter as sprites. The buy button stays disabled untilreadyresolves, becausecallthrows before then. - The ticker draws.
app.tickerruns once per animation frame; the frame callback places every runner from the prediction or the interpolator.onEnterplaces a new sprite at the entity’s current position, so it doesn’t flash at the origin for a frame. - Aim is written, not sampled. The stage gets pointer events only
with
eventMode = "static"and ahitArea, here the whole screen. The handler writes the owned runner’sangleand turns its nose at once; other clients see it throughonChange. - Teardown.
stopremoves the IRT listeners first, then the ticker callback, the pointer and key handlers, then leaves the room and destroys the layer with its children. It leaves theApplicationalone, since the arena didn’t create it.
Voice works the same in every engine; see the
Phaser guide for joinVoice with a talk
button and Proximity voice for the
details.
Common mistakes
- Destroying a sprite without
{ children: true }. The runner’s circle and nose stay in memory; PixiJS 8 doesn’t destroy children by default. - Using
app.ticker.deltaMSto move runners. Movement comes from the room’s ticks: the prediction steps at the room’s fixed timestep, and the interpolator places remote runners by the room’s clock. The ticker only says when to draw. - Pointer events on the stage with no
hitArea. An empty part of the stage then gets no events, and aim stops when the pointer leaves a sprite.