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 },
});
fielddefaultmeaning
rttMs0round trip in ms; each direction gets half
jitterMs0extra uniform delay in [0, jitterMs) per frame, never enough to reorder
loss0drop 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.engineLoaderRoom 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.