Integrations
Engines and frameworks
How @irt/client fits a rendering engine or UI framework, with the room and shared module every integration guide uses.
@irt/client doesn’t draw anything and doesn’t need an adapter: it
holds the room’s entities and tells you when they change, and your
engine draws them. The guides in this section show the same game in
Phaser, PixiJS,
Three.js, Babylon.js
and React. Each does the same six things:
| Task | IRT API | In the engine |
|---|---|---|
| Connect and show the connection’s state | new RoomClient(...), ready, waking, disconnected, restored, closed |
A status label |
| Make and drop an engine object per entity | entities.onEnter, onChange, onLeave |
Sprites, meshes, components |
| Move remote entities smoothly | room.interpolate(kind, fields), get(entity) |
The engine’s frame loop |
| Move the local player at once | room.predict(stepper, { find, sample }), render(fields) |
The same frame loop |
| Write direct input | Writable fields on entities.mine(kind) |
Pointer events |
| Ask the room for something | room.call(name, args) |
A button |
The game
Every player is a runner moved by the arrow keys, which points where the pointer is and can buy items with coins. Movement is predicted; aim is a writable field the player owns. The room and its clients import one module:
// shared.ts
import { defineCalls, defineSchema, defineStepper, entity } from "@irt/room";
export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32", angle: "f32" }, { writable: ["angle"] });
export const Wallet = entity("wallet", { coins: "u32" });
export const Match = entity("match", { round: "u16" });
export const prices = { dash: 20, shield: 50 };
export type Item = keyof typeof prices;
export const schema = defineSchema({
state: Match,
private: Wallet,
entities: [Runner],
calls: defineCalls<{ buy(args: { item: Item }): { coins: number } }>({
buy(raw) {
const item = (raw as { item?: unknown } | null)?.item;
if (typeof item !== "string" || !Object.hasOwn(prices, item)) throw new Error("unknown item");
return { item: item as Item };
},
}),
});
export interface Input {
dx: number;
dy: number;
}
const SPEED = 6;
export const stepper = defineStepper<Input>({
parseInput(raw) {
const i = raw as Partial<Input>;
if (typeof i.dx !== "number" || typeof i.dy !== "number" || Math.abs(i.dx) > 1 || Math.abs(i.dy) > 1) {
throw new TypeError("input is { dx, dy } with each from -1 to 1");
}
return { dx: i.dx, dy: i.dy };
},
step(world, inputs, dt) {
for (const r of world.all(Runner)) {
const input = inputs.get(r.owner);
if (!input) continue;
r.x += input.dx * SPEED * dt;
r.y += input.dy * SPEED * dt;
}
},
});
x and y are in world units, and angle is in radians from the +x
axis toward +y. The runner belongs to its player, so the player can write
angle; x and y aren’t writable, so the room’s stepper moves them
and the client predicts them. The room:
// room.ts
import { defineRoom, grid } from "@irt/room";
import { prices, Runner, schema, stepper } from "./shared.js";
export default defineRoom({
schema,
tickRate: 30,
stepper,
interest: grid({ cellSize: 16, range: 40, dims: "xy", around: (_room, conn) => conn.data.runner }),
voice: {},
onJoin(room, conn) {
const [existing] = room.entities.ownedBy(conn.player, Runner);
conn.data.runner = existing ?? room.entities.create(Runner, { owner: conn.player }, { owner: conn.player });
const wallet = room.private(conn);
if (wallet.coins === 0) wallet.coins = 100;
},
onWrite(room, conn, entity, next) {
return Number.isFinite(next.angle) ? next : undefined;
},
calls: {
buy(room, conn, { item }) {
const wallet = room.private(conn);
if (wallet.coins < prices[item]) throw new Error("not enough coins");
wallet.coins -= prices[item];
return { coins: wallet.coins };
},
},
onLeave(room, conn) {
room.entities.delete(conn.data.runner);
},
});
onWrite keeps a modified client from writing NaN or Infinity: an
f32 field accepts both, and every other client would draw it.
Connecting
The engine guides construct a RoomClient instead of awaiting
connect, so that their listeners are in place before the first state
update arrives:
const room = new RoomClient({ router, project: "my-game", room: roomId, schema });
room.entities.onEnter(Runner, (r) => addSprite(r));
room.on("closed", (e) => showError(e));
await room.ready;
connect resolves only after the first full state update has been
applied, and onEnter doesn’t replay entities that are already there,
so listeners added after await connect(...) miss every entity the room
had when the player joined. Either add them before ready resolves, as
here, or first walk room.entities.all(kind) for what is already there.
ready rejects as connect does, and closed fires either way, so a
closed listener is the one place to report a connection that failed or
ended. call and send throw until ready resolves.
| Event | Show |
|---|---|
waking |
“Waking the room”: the room was hibernated, and the connection waits while it wakes |
disconnected |
“Reconnecting”; the client retries by itself |
restored |
Clear the message. Every entity has left and entered again |
closed |
error.message. Reload on 4004, offer a rejoin on 4011, and tell the player they’re playing elsewhere on 4012 |
The router address and the room ID come from outside the scene: the
router is location.origin under irt dev and the region’s router once
deployed, and room IDs come from your lobby, through
createRoom, joinByCode or quickJoin
or your backend.
Entities and engine objects
Keep a map from entity ID to engine object. Create the object in
onEnter, update what isn’t drawn every frame in onChange, and
destroy it in onLeave.
- On a reconnect or a room restart, every entity leaves and enters again as a new object with the same ID. With the map above, that rebuilds the scene; don’t keep entity objects elsewhere.
onChangedoesn’t fire for the writable fields of an entity the local player owns: the client keeps its own values for those and ignores the room’s, except for a correction, after whichonCorrectfires.onEnter,onChangeandonLeavefire as each state update is applied, at the room’s tick rate or less, not once per frame. Positions belong in the frame loop.
The frame loop
Draw positions from two sources, each frame:
- Remote entities:
others.get(entity)fromroom.interpolate(Runner, ["x", "y"]), which is about 100 ms behind the room and moves smoothly between state updates. - The local player:
me.render(["x", "y"])fromroom.predict, which is ahead of the room by the inputs it hasn’t heard back about, with mispredictions smoothed away. Useme.entityfor game logic, such as whether the player stands on a pickup; it jumps when the room corrects it, so don’t draw it.
render is NaN while find finds nothing, such as before the room
has created the player’s runner. Don’t move the object until it’s a
number.
sample runs once per room tick, not per frame, on timers of its own,
so read input state there (which keys are down) rather than input
events.
Teardown
When the scene, level or component goes away:
- Remove the room listeners: each
on,onTickandonEnterreturns a function that removes it. Remove them beforeleave(), which emitsclosed, so the handler doesn’t touch destroyed objects. me.stop()andawait voice.disconnect().room.leave(). The client doesn’t reconnect, and the room hibernates 30 seconds after its last connection closes.- Remove the engine’s own listeners: pointer and key handlers on
windowoutlive the scene.
A prediction and an interpolator have no teardown of their own and live
as long as their RoomClient. A game that keeps one connection across
several scenes should create them once per client, not once per scene.
Common mistakes
- Adding
onEnterafterawait connect(...). The entities already in the room never enter. Construct aRoomClientand add listeners beforeready. - Drawing
room.entitiesvalues directly. They change once per state update and stutter. Interpolate remote entities and render the prediction for the local one. - Moving the local player with interpolation, or remote players with prediction. Interpolation draws the local player a round trip late; only the local player’s entity is predicted.
- Opening a second client when a scene restarts. The router closes the older connection of a player with 4012, and that one doesn’t reconnect. Leave the old client first.
- Sampling input in a key event.
sampleis called once per room tick; read the keys that are held at that moment.