Simulated players
Use testRoom to test your room with connected clients and a controllable clock. Tests run in your test process and can check writes, RPCs, validation, and reconnect behavior.
Your first test
irtio init scaffolds this file for you. It joins two clients, has one write a field it owns, and
checks that everyone agrees.
// irtio/room.test.ts
import { expect, test } from 'vitest';
import { testRoom } from '@irtio/testing';
import '@irtio/testing/matchers';
import room from './room.js';
test('a write to my own player reaches everyone', async () => {
const t = await testRoom(room);
const [a, b] = await t.join(2, { role: 'player' });
const mine = a.state.players.get(a.id);
expect(mine).toBeDefined();
mine!.x = 10;
a.flush();
await t.tick(2);
expect(t.state.players.get(a.id)?.x).toBe(10);
expect(b.state.players.get(a.id)?.x).toBe(10);
expect(t).toHaveConverged();
t.stop();
}); Run it with your normal test runner:
npx vitest run irtio/room.test.ts t.state is the authority: the same tracked state object your handlers mutate. a.state is one
client’s view of it. The write goes through the client’s flush window, the room’s validate, and
the delta encoder before it lands, so this three-line test already covers the whole owned-write
path.
For all harness options and methods, see the testing reference.
Everything that moves the clock is awaited
join, tick, run, and until all return promises, on both testRoom and testRelay. The
fake clock is synchronous but a client is not: joinRoom resolves in a microtask and RPC promises
settle in microtasks, so the harness drains microtasks between clock steps.
await t.tick(); // not t.tick() A missing await is the most common way to write a test that hangs or asserts on stale state.
The related trap is awaiting a call before the room has had a chance to answer it. In event mode nothing advances the clock on its own, so an awaited RPC deadlocks until your runner’s timeout. Start the call, tick, then await it.
// irtio/room.test.ts
import { expect, test } from 'vitest';
import { testRoom } from '@irtio/testing';
import '@irtio/testing/matchers';
import room from './room.js';
test('a controller cannot start the match', async () => {
const t = await testRoom(room);
// roleVerified seats the role as a JWT role claim would; a bare request gets the first role.
const host = await t.join({ role: 'host', roleVerified: true, name: 'Screen' });
const controller = await t.join({ role: 'controller', name: 'Ada' });
const started = host.call.start(); // in flight, not awaited yet
await t.tick(); // now the room sees it
await started;
expect(t.state.match.phase).toBe('asking');
const refused = expect(controller.call.start()).rejects.toThrow();
await t.tick();
await refused;
expect(t).toHaveRejected('start');
expect(t.state.match.phase).toBe('asking');
t.stop();
}); This applies to anything that waits for the room to answer, including client.requestOwnership(collection, id), which is an RPC too (the built-in one). See RPCs for what the two directions mean.
Dropping a client
client.drop() severs the in-process transport the way a real socket drop does. It is not leave(), which is a clean intentional close and correctly never reconnects. The room sees a
disconnect, and the client’s own jittered backoff (below 250 ms at first, doubling up to 30 s)
runs on the harness’s fake clock, so advancing time is all it takes.
// irtio/reconnect.test.ts
import { expect, test } from 'vitest';
import { testRoom } from '@irtio/testing';
import '@irtio/testing/matchers';
import room from './room.js';
test('a dropped client resumes with the same identity and its state intact', async () => {
const t = await testRoom(room);
const a = await t.join({ role: 'player', name: 'Ada' });
const b = await t.join({ role: 'player', name: 'Ben' });
a.state.players.get(a.id)!.x = 42;
a.flush();
await t.tick(2);
const id = a.id;
a.drop();
expect(a.status).toBe('reconnecting');
await t.until(() => a.status === 'connected');
expect(a.id).toBe(id); // a resume, not a new join
expect(a.reconnects).toBe(1);
expect(t.state.players.get(id)?.x).toBe(42);
expect(b.state.players.get(id)?.x).toBe(42);
expect(t).toHaveConverged();
t.stop();
}); drop() is synchronous: the status flip and the armed backoff timer happen before it returns, so
you can assert on 'reconnecting' without awaiting anything. Whether a reconnecting client keeps
its entity is your room’s decision, made in onJoin by checking ctx.reconnecting. See Reference: room.
Latency, jitter, and loss
The in-process link takes a network model. Delays are applied on the fake clock, so a room with an
80 ms round trip still runs at full speed. It needs 80 ms of t.run() to see a reply.
const t = await testRoom(room, {
latency: { rttMs: 80, jitterMs: 20, loss: 0.02 },
}); | field | default | meaning |
|---|---|---|
rttMs | 0 | round trip in ms; each direction gets half |
jitterMs | 0 | extra uniform delay in [0, jitterMs) per frame, never enough to reorder |
loss | 0 | drop probability in [0, 1) for state frames |
Loss applies to state frames only: WRITE, DELTA, CORRECT, and MSG. Session and RPC frames
(HELLO, WELCOME, CALL, REPLY, ERROR, PING, PONG) are never dropped, because a
WebSocket is a reliable stream and a lost WELCOME would hang the join rather than teach the test
anything.
Everything the model does is driven by the seeded generator, so a run replays exactly from its seed. t.dropped counts what it discarded. Per-client latency in a join spec overrides the
room-wide setting, which is how you give one client a bad connection and the rest a clean one.
const t = await testRoom(room, { seed: 7 });
const good = await t.join({ role: 'player' });
const bad = await t.join({ role: 'player', latency: { rttMs: 300, loss: 0.1 } }); Physics rooms need initPhysics or initMatter first
The physics engine loads asynchronously and room construction is synchronous, so the engine has to
be ready before any physics room exists. Await the loader for your engine once, in a beforeAll,
before the first testRoom call in the file. Without it the room throws on construction with a
message saying exactly this.
Which loader depends on the engine your room config declares:
physics.engine | Loader | Room accessor |
|---|---|---|
'rapier3d' | initPhysics() | room.physics |
'rapier2d' | initRapier2d() | room.physicsRapier2d |
'matter2d' | initMatter() | room.physics2d |
All three are re-exported from @irtio/testing, and all are safe to call more than once. The two
Rapier loads instantiate WASM, from separate packages with separate instances, and matter.js’s is
a plain dynamic import, which is why the matter one is quicker and why it still has to be awaited.
// irtio/room.test.ts
import { beforeAll, expect, test } from 'vitest';
import { initPhysics, testRoom } from '@irtio/testing';
import '@irtio/testing/matchers';
import room from './room.js';
beforeAll(async () => {
await initPhysics();
});
test('a player falls to the floor and steers with an intent', async () => {
const t = await testRoom(room);
const [a] = await t.join(1, { role: 'player' });
await t.tick(60);
const ball = t.state.players.get(a.id);
expect(ball).toBeDefined();
expect(ball!.y).toBeLessThan(1); // it fell and came to rest
const before = ball!.x;
a.state.players.get(a.id)!.ax = 1;
a.flush();
await t.tick(20);
expect(t.state.players.get(a.id)!.x).toBeGreaterThan(before);
expect(t).toHaveNoVisibilityLeaks();
t.stop();
}); Both loaders are re-exported from @irtio/testing, so a test imports one package rather than the
runtime’s internals. A matter2d room’s test is the same file with initMatter() in the beforeAll and the room’s own body fields in the assertions.
Two habits help with physics tests. Settle the world with t.until(...) rather than a fixed tick
count, since settling is not instant and until still fails fast on a genuinely stuck body. And
copy values out of a record before comparing, because t.state.players.get(id) returns the same
mutable object every call, so a captured reference always reads as unchanged.
Test scope
Use room tests for handlers, writes, RPCs, and simulated reconnects. For network behavior, hibernation round trips, and capacity, run against irtio dev with load tests.
Related
- Invariants and load runs for the socket-level check
- Testing in CI for running both on every push