# 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](/docs/integrations/phaser), [PixiJS](/docs/integrations/pixi),
[Three.js](/docs/integrations/three), [Babylon.js](/docs/integrations/babylon)
and [React](/docs/integrations/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:

```ts
// 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:

```ts
// 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:

```ts
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](/docs/reference/client-sdk#http-helpers-for-games-without-a-backend)
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.
