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.
  • onChange doesn’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 which onCorrect fires.
  • onEnter, onChange and onLeave fire 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) from room.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"]) from room.predict, which is ahead of the room by the inputs it hasn’t heard back about, with mispredictions smoothed away. Use me.entity for 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:

  1. Remove the room listeners: each on, onTick and onEnter returns a function that removes it. Remove them before leave(), which emits closed, so the handler doesn’t touch destroyed objects.
  2. me.stop() and await voice.disconnect().
  3. room.leave(). The client doesn’t reconnect, and the room hibernates 30 seconds after its last connection closes.
  4. Remove the engine’s own listeners: pointer and key handlers on window outlive 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 onEnter after await connect(...). The entities already in the room never enter. Construct a RoomClient and add listeners before ready.
  • Drawing room.entities values 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. sample is called once per room tick; read the keys that are held at that moment.

View this page as Markdown