Room testing API

testRoom runs room logic with real client behavior and a controllable clock. Start with a room test.

The harness handle

testRoom(definition, options?) takes your room definition, the default export of defineRoom.

To name the handle’s type, for a variable shared across beforeEach and the tests, use TestRoomOf<typeof room>. TestRoom’s own type parameter is the schema, not the definition, so TestRoom<typeof room> does not typecheck. TestRoomOf takes either:

import { type TestRoomOf, testRoom } from '@irtio/testing';

let t: TestRoomOf<typeof room>;
beforeEach(async () => {
  t = await testRoom(room);
});
optiondefaultwhat it does
seed1seeds room.random() and the latency jitter and loss generator
latencynone{ rttMs, jitterMs, loss } on the in-process link
modethe room’s ownoverrides 'tick' or 'event'
tickRatethe room’s ownchanges what one t.tick() costs in fake milliseconds
roomId'test-room'the room code every client joins
writeIntervalMs50the clients’ owned-write flush window
failOnHandlerErrortruethrow out of t.tick / t.run / t.until / t.join when a room handler throws
autoPumptrueawaiting a client promise advances the clock until it settles
autoPumpTicks200how many ticks the auto-pump advances before it gives up
traceRpcfalserecord every RPC, readable as t.rpcTrace

The handle it returns:

memberwhat it is
await t.join(spec?)one client
await t.join(n, spec?)n clients, in join order
await t.tick(n?)run n server ticks (default 1)
await t.run(ms)advance ms of fake time, firing ticks and timers in order
await t.until(pred, opts?)tick until pred() holds, or reject when it never does
t.statethe authority state your handlers mutate
t.clientsevery client that joined
t.traceevery frame that crossed the seam, both directions, with timestamps
t.rejectionsevery RPC call that came back as an error reply
t.handlerErrorsevery handler throw the runtime caught, with the handler name and tick
t.droppedframes the loss model discarded
t.now, t.tickCount, t.modefake-clock time, tick counter, effective mode
t.predictionStats()per-client corrections, misprediction magnitude, snaps, replayed writes; for a client joined with an engine block, predicting: true and the predictor’s own counters under engine (snaps, overCap, absent, …)
t.bandwidth()outbound bytes per client and the per-tick average
t.physicsread, teleport and push bodies in the room’s world. See Physics in a test
t.rpcTracethe room’s RPC trace, or undefined unless traceRpc: true
await t.pingNow(clientId?)fire the client’s 2 s ping now. See Pinning the stamp phase
t.kv(playerId, key?)what the room stored with room.kv.set: one value, or every key for the player. See Player progress
t.leaderboard(board, { bucket }?)the rows the room submitted or incremented, best first
t.checkVisibility()collections a client can see that its role must not
t.convergence()per-client differences from the authority
t.stop()every client leaves and the room stops

A throwing handler fails the test

By default, a handler throw fails the clock operation that ran it, with the handler name and message. Set failOnHandlerError: false when testing a throwing handler. t.handlerErrors records errors with either setting.

t.core and t.host are escape hatches onto the real RoomCore and its host, for the cases below where you want to drive the room directly.

t.until steps the clock by one tick interval per attempt in tick mode and by 1 ms in event mode, where a tick has no duration. Pass { stepMs } to cover a long wait in fewer attempts, or use t.run(ms) when you know how much time you need. { maxTicks } defaults to 1000.

Awaiting a client advances the clock

Awaiting anything a test client returns a promise for runs the clock until it settles. That is client.call.*, client.requestOwnership and client.leave:

await host.call.start();
const result = await player.call.answer({ choice: 1 });

The pump starts when you await the promise, not when you make the call, so you can still hold a call and assert on the state before the room has answered:

const started = host.call.start();
expect(t.state.match.phase).toBe('lobby'); // the handler has not run yet
await started;
expect(t.state.match.phase).toBe('question');

It gives up after autoPumpTicks ticks and says so, naming the cap and the operation that hung. Pass autoPump: false to turn it off for a test whose subject is that ordering; then a call needs await t.tick() before its promise can settle.

Physics in a test

t.physics is a small handle on the room’s live world. Positions and velocities are { x, y, z }; on a 2D engine z is 0.

memberwhat it does
t.physics.engine'rapier3d', 'matter2d', 'rapier2d', or undefined for a room with no physics
t.physics.position(collection, id)where that body is now
t.physics.velocity(collection, id)its linear velocity
t.physics.teleport(collection, id, { x, y, z? })move it, waking it
t.physics.impulse(collection, id, { x, y, z? })push it, waking it
t.physics.teleport('crates', 'c1', { x: 500, y: 250 });
await t.tick(); // the room writes bodies back to state in its own sync
expect(t.state.crates.get('c1')?.x).toBeCloseTo(500, 1);

A teleport or an impulse reaches your schema on the room’s next tick, because that is when the room reads the world back. Reading a body with no physics configured, a collection with no bodies, or an id that is not there throws and says which. For anything wider, t.core.physics is the real runtime.

Pinning the stamp phase

Clients refresh their server tick from state deltas and two-second ping responses. In a quiet room, use t.pingNow() to start a test from a known ping phase.

t.pingNow() fires the ping now, which puts every client back at a known phase:

const t = await testRoom(quiet, { traceRpc: true });
const a = await t.join();
await t.run(500);

await t.pingNow();
await a.call.shoot({});
// fresh: the client's stamp is current

await t.pingNow();
await t.run(1000);
await a.call.shoot({});
// stale by about 1000 ms worth of ticks

const lag = t.rpcTrace?.stampLag.find((s) => s.clientId === a.id)?.lag;

Pass a client id to ping just that one.

NPCs

ctx.room.spawnNPC(...) runs in testRoom. The script named in your room’s npcs map is started on a real in-process client, on the same fake clock as the room and every t.join() client, so an NPC joins, appears in t.state, and writes its own records exactly as it does in a deployed room. Its session carries the npc flag, so room.clients reports it as one and it does not keep an idle room awake.

const t = await testRoom(arena);
const a = await t.join({ role: 'player' });

await a.call.addBot();      // the RPC that calls ctx.room.spawnNPC
await t.run(200);           // the NPC joins on the fake clock

expect(t.state.players.size).toBe(2);
await t.tick(20);           // it plays

A script parked on npc.wait(ms) is driven step by step by a long t.run(...): the run walks the fake clock from one due timer to the next and lets the script resume at each one, so t.run(1000) gives a script looping on npc.wait(50) about twenty steps. t.tick(n) and t.until(...) drive it just as well when you want to assert tick by tick.

handle.despawn() and ctx.room.despawnNPC(clientId) stop the script and leave the room, so onLeave runs and the entity goes. t.stop() stops every NPC still running.

For an NPC over real sockets in a real tenant, use irtio simulate.

Join specs

join takes { role, roleVerified, auth, playerId, name, latency, interpDelayMs, rpc }, plus one of physics, physics2d or physicsCustom for a physics room. The rpc field holds your implementations of the schema’s client-direction RPCs and goes straight to joinRoom. Without it the client answers “no client implementation” and the only path you can test is the failing one.

role is a request, as it is on a server: the client gets the schema’s first role unless the schema sets clientRoles: true. Set roleVerified: true to seat the role as if a JWT role claim had named it. auth is what room code reads as ctx.auth, 'key' by default.

const seen: string[] = [];
const screen = await t.join({
  role: 'host',
  roleVerified: true,
  rpc: { showResults: ({ winner }: { winner: string }) => void seen.push(winner) },
});

In a physics room, pass the same engine block your game client passes to joinRoom, and the test client builds a local world and predicts on the fake clock as a shipped client does. join waits for the engine to load and throws if it never does. Without the block a client only interpolates, and toStayWithinPrediction refuses to judge it.

import rapier3d from '@irtio/client/rapier3d';
import * as world from './world.js';

const physics = { module: rapier3d, gravity: world.gravity, setup: world.setup,
  bodies: world.bodies, intents: world.intents };
const [a, b] = await t.join(2, { role: 'player', physics });
await t.run(4000);
expect(t.predictionStats().get(a.id)?.engine?.absent).toBe(0);
expect(t).toStayWithinPrediction({ maxSnaps: 0, maxOverCap: 0, maxAbsent: 0 });

Player progress

Room code sees ctx.playerId as the player’s identity. On a server, a JWT subject or a player credential fixes it. In a test, each join is a new player whose ctx.playerId is its client id, unless you pass playerId. Join again with the same playerId after a leave(), and the room sees the same player come back with everything it stored under room.kv for them.

t.kv(playerId, key) reads what the room stored, or undefined for a key it never set. t.kv(playerId) returns every key for that player as { key: value }. A write appears once the room’s call has been answered, which is the next t.tick() in a settled test.

test('progress survives rejoin', async () => {
  const t = await testRoom(room);
  const a = await t.join({ playerId: 'alice' });
  await a.call.levelUp();
  await t.tick();
  expect(t.kv('alice', 'level')).toBe('1');

  await a.leave();
  const back = await t.join({ playerId: 'alice' });
  await t.until(() => t.state.players.get(back.id)?.level === 1);
});

t.leaderboard(board) returns the board’s rows, best score first, as { playerId, score, name?, bucket? }. A submit keeps a player’s best score, an increment adds to it, and name is the display name last sent with the row, as on a server. Pass { bucket } to read one cohort of a bucketed board. Without it, every row comes back with its own bucket. The test room holds only the current period. The platform rotates daily and weekly boards, not the room, so no row carries a period.

await a.call.finish({ time: 4210 });
await t.tick();
expect(t.leaderboard('fastest')).toEqual([{ playerId: 'alice', score: 4210, name: 'Alice' }]);

Three names that differ from the browser SDK

A TestClient is a real client room with three deliberate renames.

on a TestClientmeans
client.idthe client id, the same value as room.me
client.roomIdthe room code, which is what room.id means everywhere else
client.viewan alias for client.state

client.id shadowing the room code makes two-client tests read the way you would say them out loud (expect(t.state.cards.ownerOf('c1')).toBe(a.id)). It is the opposite of @irtio/client, so be sure which one you are holding.

Holding an owned instance across ticks

A handle you take from client.state.players.get(id) stays valid across t.tick(), t.run() and client.flush(), including one taken right after t.join() before any tick. The one exception is an ownership change: after a grant or a hand-off, read the instance again, as ownership describes.

A handle you take from t.state on the room side is the room’s own instance. It stays the same object across t.tick(), reads the latest values the owner has sent, and writes to it reach the room and every client.

Matchers

Import @irtio/testing/matchers once per test file. It is a side-effect import that registers five matchers with Vitest’s expect.

matcherasserts
expect(t).toHaveNoVisibilityLeaks()no client’s view or received frame carried a collection its role must not see, and no spatial id outside that client’s authoritative neighborhood
expect(t).toHaveConverged()every client’s view equals the authority projected to that client’s role, and its interpolated room.render too once its stream has been idle for interpDelayMs
expect(t).toHaveRejected('start')some call to that RPC came back as an error reply
expect(t).toStayUnderBandwidth(512)no client averaged more than that many outbound bytes per tick
expect(t).toStayWithinPrediction({ maxMagnitude: 5, maxSnaps: 0 })no correction snapped a client’s prediction further than maxMagnitude numeric units, and no client outran its resim window more than maxSnaps times. maxOverCap and maxAbsent bound the predictor’s overCap and absent. Throws when no client joined, or in a physics room when a client has no engine block (allowUnpredicted: true skips such clients)

Every failure message ends with the last ten frames of the trace, which is usually enough to see what happened:

expected no visibility leaks, found 1:
  c2 (player) saw dots [far] in its frame at tick 41 (viewer c2@(0,0), dots far@(2,0), radius 1)

trace (last 10):
  1980ms tick 40 → c2 WRITE 24B
  2000ms tick 41 ← c2 DELTA 61B

All five are also exported from @irtio/testing as plain functions, so a script with no test runner can call them. The main entry point does not import Vitest at all.

A test that asserts a role cannot see something should also prove the detector fires. t.pretendRole(clientId, role) makes checkVisibility judge that client as a different role from now on without telling the room, which is the only way to write a failing case for your own visibility rules. See Visibility for how the rules are declared.

What a hidden collection looks like in a client’s view

A collection a client’s role cannot see is not missing from client.view. Frames are positional over the schema’s collections, so the slot is always sent: a hidden singleton arrives as its declared defaults ('', 0, false), and a hidden entity collection arrives with no ids. No value crosses the wire, and toHaveNoVisibilityLeaks still holds.

So assert on values, not on the key:

expect(player.view.hidden.deck).toBe(''); // right: no data reached the player
expect(player.view.hidden).toBeUndefined(); // wrong: the property is always there

Relay rooms

Rooms with no schema get their own entry point with the same shape and the same fake clock. testRelay gives you presence and raw messages, and nothing else, because that is all a relay room has.

// irtio/relay.test.ts
import { expect, test } from 'vitest';

import { testRelay } from '@irtio/testing';

test('a broadcast reaches everyone but the sender', async () => {
  const t = await testRelay();
  const [a, b, c] = await t.join(3);

  a.message('all', new Uint8Array([1, 2, 3]));
  await t.until(() => b.received.length > 0 && c.received.length > 0);

  expect(b.received[0]?.from).toBe(a.me);
  expect([...(b.received[0]?.bytes ?? [])]).toEqual([1, 2, 3]);
  expect(a.received).toHaveLength(0);
  t.stop();
});

A TestRelayClient is a real joinRelay room plus drop() and received, a recording of everything onMessage delivered, so you can assert without wiring a callback. A targeted message (a.message(b.me, bytes)) reaches only its target. testRelay({ seed, latency, roomId, maxClients }) takes the same latency model as testRoom.