Rooms

Testing rooms

Patterns for testing rooms with @irt/testing, from ticks and clocks to hibernation, crashes, reconnects, ownership, calls and migrations, and running them in CI.

@irt/testing runs a room in process, with the real @irt/client as its clients and a clock that moves only when the test moves it. Tests need no network, no irt dev and no account, so they run the same on a laptop and in CI. This guide shows how to test what rooms usually get wrong; every call is in the @irt/testing reference.

npx irt test            # vitest run, in the directory of irt.config.ts
npx irt test src/room   # arguments go to vitest

The shape of a test

// counter.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

export const Count = entity("count", { n: "u32" });
export const Mine = entity("mine", { clicks: "u32" });

export default defineRoom({
  schema: defineSchema({ state: Count, private: Mine }),
  tickRate: 20,
  onMessage(room, conn, message) {
    if (message !== "click") return;
    room.state.n += 1;
    room.private(conn).clicks += 1;
  },
});
// counter.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room from "./counter.js";

test("every player sees the total and only their own clicks", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });

  alice.send("click");
  alice.send("click");
  bob.send("click");
  await t.host.tick();

  expect(bob.client.state.n).toBe(3);
  expect(alice.private.clicks).toBe(2);
  expect(bob.private.clicks).toBe(1);
  expect(t.live.state.n).toBe(3);
});
  • testRoom(def) is one room, "room", in project "test". createTestHost(def) holds several rooms of one build in one room host.
  • connect({ player }) signs a token for that player ID; without player the client is a guest. meta sets the token’s meta claim.
  • Changes reach clients at the end of a tick, so await t.host.tick() before asserting on what a client sees.
  • t.live is the running room: read it, or write its entities to set up a case, from the test.

Time

Call Moves the clock Use it to
t.host.tick(n) n / tickRate seconds; every running room runs exactly n ticks Step the simulation
t.host.advance(ms) ms, running every timer that falls due Cross timeouts: idle hibernation after 30 s, call timeouts, reconnect backoff
t.host.clock.jump(ms) ms, running nothing until the next tick or advance Simulate a blocked event loop

Room code’s own setTimeout and Date.now() use real time, which the test doesn’t move. Time things in ticks instead: store endsAt = room.tick + seconds * tickRate in room state and compare in onTick. For wall-clock time, use room.now(), which reads the test clock: each host’s clock starts at the real time when it’s created, or at createTestHost(room, { startAt }) / testRoom(room, { startAt }), milliseconds since the epoch, for a test that needs a known date. Two hosts made a while apart have clocks that far apart, so room state moved from one to the other wakes with that elapsed; give the second a startAt past the first’s clock.wall() to set the gap.

// catch-up.test.ts
import { defineRoom } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

test("a room that stalls runs at most 4 ticks to catch up", async () => {
  const t = testRoom(defineRoom({ tickRate: 20 }));
  await t.wake();
  const before = t.live.tick;
  t.host.clock.jump(1000);
  await t.host.advance(0);
  expect(t.live.tick - before).toBe(4);
});

Hibernation and crashes

The three ways a room loses its process behave differently, and a room should be tested through each:

Call Runs onLeave Clients Room starts again with
t.hibernate() yes, for every connection closed with 4011; they don’t reconnect onWake, when the next client connects or on t.wake()
t.host.crash() no reconnect by themselves onWake, then onJoin for each client that reconnects
client.drop() yes, for that connection that client reconnects with backoff nothing; the room keeps running
// avatars.ts
import { defineRoom, defineSchema, entity } from "@irt/room";

export const Avatar = entity("avatar", { owner: "string", x: "f32" });
export const Wins = entity("wins", { wins: "u16" });

export default defineRoom({
  schema: defineSchema({ private: Wins, entities: [Avatar] }),
  onJoin(room, conn) {
    const existing = [...room.entities.all(Avatar)].find((a) => a.owner === conn.player);
    conn.data.avatar = existing ?? room.entities.create(Avatar, { owner: conn.player });
  },
  onMessage(room, conn, message) {
    if (message === "win") room.private(conn).wins += 1;
  },
  onPlayerLeave(room, player) {
    for (const e of room.entities.all(Avatar)) if (e.owner === player) room.entities.delete(e);
  },
});
// avatars.test.ts
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";
import room, { Avatar } from "./avatars.js";

test("a crash keeps one avatar per player", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  await t.host.crash();
  await alice.reconnected();
  expect(alice.view(Avatar)).toHaveLength(1);
  expect(alice.events).toContain("restored");
});

test("wins outlast hibernation and the avatar doesn't", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  alice.send("win");
  await t.host.tick();
  await t.hibernate();
  expect(alice.closedWith).toBe(4011);
  expect(t.stored().entities).toEqual([]);

  const back = await t.connect({ player: "alice" });
  expect(back.private.wins).toBe(1);
  expect(back.view(Avatar)).toHaveLength(1);
});

test("a dropped connection keeps the avatar, and leaving for good removes it", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });
  alice.drop();
  await alice.reconnected();
  await t.host.tick();
  expect(bob.view(Avatar).map((a) => a.owner).sort()).toEqual(["alice", "bob"]);
  await alice.leave();
  // onPlayerLeave runs once the presence grace, 10 seconds, has passed.
  await t.host.advance(10_000);
  await t.host.tick();
  expect(bob.view(Avatar).map((a) => a.owner)).toEqual(["bob"]);
});
  • t.stored() is the room state a wake would start from, as RoomData. Assert on it to check what’s saved without waking the room.
  • client.events records "waking", "disconnected 1006", "restored" and "closed 4011" in order; closedWith is the final close code.
  • reconnected() waits for the reconnect after the client’s last drop or crash, and resolves at once if it already happened. After a crash every client reconnects while the clock moves for the first, so await each in turn: await bob.reconnected(); await alice.reconnected();.
  • t.host.advance(31_000) after the last client leaves hibernates the room by its idle timeout, as in production.

Calls

client.call is the real client’s. The room runs a call’s handler as it arrives, between ticks. A call that changed room state replies only after the next tick has saved the change, so start it, tick, then await it; awaited before a tick, it never resolves, since the test clock doesn’t move by itself. A call that changed nothing, or that failed, answers at once:

// shop.test.ts
import { defineCalls, defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const Purse = entity("purse", { coins: "u32" });
const room = defineRoom({
  schema: defineSchema({ private: Purse, calls: defineCalls<{ buy(args: { price: number }): { left: number } }>() }),
  onJoin(room, conn) {
    room.private(conn).coins = 10;
  },
  calls: {
    buy(room, conn, { price }) {
      const purse = room.private(conn);
      if (!Number.isInteger(price) || price < 0) throw new Error("price must be a whole number");
      if (purse.coins < price) throw new Error("not enough coins");
      purse.coins -= price;
      return { left: purse.coins };
    },
  },
});

test("a call resolves to the reply or rejects with the handler's error", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bought = alice.client.call("buy", { price: 4 });
  await expect(alice.client.call("buy", { price: 40 })).rejects.toThrow("call buy failed in the room: not enough coins");
  await t.host.tick();
  await expect(bought).resolves.toEqual({ left: 6 });
  expect(alice.private.coins).toBe(6);
});

test("a call in flight fails when the connection drops", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const pending = alice.client.call("buy", { price: 1 });
  alice.drop();
  await expect(pending).rejects.toThrow(/it may or may not have run/);
});

Ownership and corrections

client.mine(kind) gives the entities a test client’s player owns, with writable fields to set. expectCorrected asserts the room overrode a write:

// cursor.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const Cursor = entity("cursor", { x: "f32" }, { writable: ["x"] });
const room = defineRoom({
  schema: defineSchema({ entities: [Cursor] }),
  onJoin(room, conn) {
    if ([...room.entities.ownedBy(conn.player, Cursor)].length === 0) room.entities.create(Cursor, {}, { owner: conn.player });
  },
  onWrite(_room, _conn, _entity, next) {
    return { ...next, x: Math.min(Math.max(next.x as number, 0), 100) };
  },
});

test("writes in bounds reach others, and the room clamps the rest", async () => {
  const t = testRoom(room);
  const alice = await t.connect({ player: "alice" });
  const bob = await t.connect({ player: "bob" });
  const [cursor] = alice.mine(Cursor);

  cursor!.x = 40;
  await t.host.tick(2);
  expect(bob.view(Cursor).find((c) => c.id === cursor!.id)).toMatchObject({ x: 40 });

  cursor!.x = 500;
  await t.host.tick(2);
  alice.expectCorrected(cursor!.id, "x");
  expect(cursor!.x).toBe(100);
});

Several rooms

// isolation.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { createTestHost, RoomStoppedError } from "@irt/testing";
import { expect, test } from "vitest";

const Score = entity("score", { n: "u32" });
const room = defineRoom({
  schema: defineSchema({ state: Score }),
  onMessage(room, _conn, message) {
    if (message === "boom") room.state.n = 999;
  },
  onTick(room) {
    if (room.state.n === 999) throw new Error("boom");
  },
});

test("a room that throws stops alone", async () => {
  const host = createTestHost(room);
  const a = await host.room("a").connect({ player: "alice" });
  const b = await host.room("b").connect({ player: "bob" });
  a.send("boom");
  await expect(host.tick()).rejects.toThrow(RoomStoppedError);
  expect(a.closedWith).toBe(4007);
  await host.tick();
  expect(b.client.status).toBe("open");
});

tick and advance throw RoomStoppedError when a room stopped, with its tick and the stack. A test that doesn’t expect it fails there, which is usually what you want.

Migrations

Test a migration by making room state with the old build and waking it with the new one. Both hosts’ storage is the same in-memory format, so copy it across:

// migration.test.ts
import { defineRoom, defineSchema, entity } from "@irt/room";
import { testRoom } from "@irt/testing";
import { expect, test } from "vitest";

const PlayerV1 = entity("player", { owner: "string", hp: "u8" });
const v1 = defineRoom({
  schema: defineSchema({ entities: [PlayerV1] }),
  onCreate(room) {
    room.entities.create(PlayerV1, { owner: "alice", hp: 7 });
  },
});

const PlayerV2 = entity("player", { owner: "string", health: "u16" });
const v2 = defineRoom({
  schema: defineSchema({ entities: [PlayerV2] }),
  version: 2,
  migrations: {
    2: (data) => ({
      ...data,
      entities: data.entities.map((e) => (e.kind === "player" ? { ...e, values: { owner: e.values.owner, health: e.values.hp } } : e)),
    }),
  },
});

test("version 2 wakes rooms saved by version 1", async () => {
  const before = testRoom(v1);
  await before.wake();
  await before.hibernate();
  const { base, log } = before.host.storage.load("test", "room");

  const after = testRoom(v2);
  after.host.storage.writeBase("test", "room", base);
  for (const batch of log) after.host.storage.append("test", "room", batch);
  await after.wake();
  expect([...after.live.entities.all(PlayerV2)].map((p) => p.health)).toEqual([7]);
  expect(after.stored().version).toBe(2);
});

test("without the migration the room stops and says why", async () => {
  const before = testRoom(v1);
  await before.wake();
  await before.hibernate();
  const after = testRoom({ ...v2, migrations: {} });
  after.host.storage.writeBase("test", "room", before.host.storage.load("test", "room").base);
  await expect(after.wake()).rejects.toThrow(/no migration to version 2/);
});

Keep a saved copy of real room state from each released version in the repository, as a file written from storage.load(...).base, and wake it with the current build in a test, so a migration is tested against what rooms really hold.

Prediction and interest

  • Prediction: client.predict(stepper, { find, sample }) samples once per room tick as the test moves the clock. Assert on settled, not on pending; see Testing it.
  • Interest: expectSees(id) and expectNotSees(id) fail with what the client sees instead; see the reference.
  • Wire size: client.lastUpdate has the last state update’s parts and bytes, to keep a game’s bandwidth in check.

Without a backend

The host has createRoom, joinByCode and listPublic, with the project’s settings applied; pass settings with testRoom(def, { settings }). See Games without a backend.

In CI

Tests need Node 24 or later and nothing else:

# .github/workflows/test.yml
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
      - run: npm ci
      - run: npx irt test

irt test exits non-zero when a test fails. To deploy from CI after the tests pass, see Deploying from CI.

Common mistakes

  • Asserting before a tick. State updates go out at the end of a tick.
  • Starting a call that fails and awaiting it later. The rejection arrives as soon as the room answers, and Vitest reports it as unhandled if nothing awaits it yet. Await calls where you make them.
  • Using setTimeout to wait. Test time moves only with tick and advance; real waiting changes nothing.
  • Testing only the happy path. Crash, drop and hibernate each run different hooks. A room that passes one can fail another.
  • Reconnecting a client after hibernate(). It’s closed with 4011 and doesn’t come back; connect a new one.
  • Sharing a host between tests. Make one per test, so a stopped room or leftover client can’t leak into the next.

View this page as Markdown