Rooms
Simulating players
Run the room locally with dozens of scripted players, check invariants such as visibility leaks and lost writes every tick, and read the trace.
irt simulate runs the project’s room on your machine with simulated
players, checks invariants after every tick, prints a summary and writes
a trace. Each player is a bot with a real @irt/client connection, so
state updates, owner writes, inputs and calls go through the same
protocol as in a browser. It runs against local rooms only, never a
deployed project.
$ npx irt simulate --players 20
20 players, 600 ticks (30.0 s at 20 Hz), seed 1, in 2.1 s
pass visibility 11980 client views checked against the room's interest
pass prediction 0 mispredictions, at most 0 for one bot (allowed 10)
skip owned-writes no bot wrote an owned entity with bot.write
pass room-errors 0 errors logged by the room
pass limits room state peaked at 1840 bytes (allowed 33554432), 20 entities
skip reconnects no bot reconnected; set drops or call bot.drop()
pass script 0 errors thrown by the script and behaviours
traffic per player: 1.42 KB/s down, 0.31 KB/s up
wall time per tick: p50 0.41 ms, p95 0.9 ms, max 3.2 ms
trace: .irt/simulate/trace.jsonl
It exits with 1 when an invariant fails, so it can run in CI beside
irt test.
How it runs
The room runs in process on the same test host as
@irt/testing: a controlled clock, the local
router and in-memory room state, with no network. Time moves one tick at
a time, so a run takes far less wall time than room time. Bots join one
after another before the first tick, then the room runs --ticks ticks,
then a second or so more with the bots’ behaviours stopped, so the last
writes and reconnects settle.
A run is deterministic for a seed: the bots’ choices, the simulated
network and Math.random in room code all come from it, and Date.now
follows the test clock. Running twice with one seed writes the same
trace, apart from wall times.
Without a script, every bot runs the built-in behaviours (walk by
default). With an irt.sim.ts in the project, or --script <file>,
the script decides.
Invariants
| Name | Fails when |
|---|---|
visibility |
A client holds an entity its interest excludes. Each tick, the simulation calls the room’s keysOf, subscriptionsOf and sees afresh for every connection and entity, and compares with what each client holds at the tick it last applied. Skipped for rooms without interest. |
prediction |
A bot’s predictions are corrected by the room more than maxCorrections times (default 10). |
owned-writes |
A write made with bot.write was neither applied by the room nor corrected: the room never held the value and sent no correction. Corrections are expected; silent losses aren’t. |
room-errors |
The room logs an error, such as a throw in onMessage, a call or onWrite, or stops. |
limits |
Room state grows past maxRoomState (default the 32 MB a room stops at), or a connection goes over a state sync cap: interest keys, visible entities or presence. |
reconnects |
After a bot reconnects, its shared state, private state or entities differ from what the room held at that tick, or it’s missing entities it should see. |
script |
The script or a behaviour throws. |
Each one passes, fails or is skipped when nothing exercised it; the skip line says what would. A script adds its own invariants by name.
The visibility check finds rooms whose interest functions read something
other than the entity and connection they’re given. Here keysOf reads a
team table that onTick changes, but the room recomputes an entity’s
keys only when the entity changes, so a player who switches teams stays
visible to the old team:
const teams = new Map<string, string>();
const interest = {
keysOf: (e) => [teams.get(e.owner)], // reads outside the entity
subscriptionsOf: (conn) => [teams.get(conn.player)],
};
irt simulate reports it as a visibility failure naming the player who
still sees the entity, the tick, and the entity’s values on both sides.
Store the team on the entity instead, so changing it changes the keys.
A script
A script is a module whose default export is defineSimulation(room, {...}) from @irt/simulate. The CLI bundles it with the room, and
resolves @irt/simulate to its own copy, so it needs nothing installed;
add @irt/simulate as a devDependency for your editor’s types.
// schema.ts
import { defineCalls, defineSchema, defineStepper, entity } from "@irt/room";
export const Runner = entity("runner", { owner: "string", x: "f32", y: "f32" });
export const Score = entity("score", { laps: "u32" });
export const schema = defineSchema({
state: Score,
entities: [Runner],
calls: defineCalls<{ boost(args: { power: number }): { ok: boolean } }>(),
});
export const stepper = defineStepper<{ dx: number; dy: number }>({
step(world, inputs, dt) {
for (const r of world.all(Runner)) {
const i = inputs.get(r.owner);
if (i) r.x += i.dx * 10 * dt;
}
},
});
// room.ts
import { defineRoom } from "@irt/room";
import { Runner, schema, stepper } from "./schema.js";
export default defineRoom({
schema,
stepper,
onJoin(room, conn) {
if (![...room.entities.all(Runner)].some((r) => r.owner === conn.player)) room.entities.create(Runner, { owner: conn.player });
},
calls: {
boost: (_room, _conn, { power }) => ({ ok: power <= 3 }),
},
});
// irt.sim.ts
import { type Behaviour, chat, defineSimulation, walk } from "@irt/simulate";
import room from "./room.js";
import { Runner } from "./schema.js";
const booster: Behaviour<any, any, any> = {
name: "booster",
async start(bot) {
await bot.wait(20 + Math.floor(bot.random() * 40));
await bot.call("boost", { power: 2 });
},
};
export default defineSimulation(room, {
players: 16,
ticks: 1200,
seed: 42,
network: { rttMs: 120, jitterMs: 20, loss: 0.02 },
drops: 4,
behaviour: [walk(), chat({ every: 10 }), booster],
async run(sim) {
await sim.wait(200);
const late = await sim.spawn({ player: "late", network: { rttMs: 300 } });
sim.log("late joined", { tick: sim.tick });
await sim.crash();
await late.until(() => late.client.status === "open");
},
invariants: {
"one runner per player": ({ room }) => {
const owners = [...room.entities.all(Runner)].map((r) => r.owner);
return new Set(owners).size === owners.length || `runners: ${owners.join(", ")}`;
},
},
});
defineSimulation(room, spec) takes:
| Field | Default | Meaning |
|---|---|---|
players |
8 | Bots that join before the first tick |
ticks |
30 s of ticks | Ticks to run after they’ve joined |
seed |
1 | Seeds every random choice in the run |
network |
none | { rttMs, jitterMs?, loss? } for every bot, as simulateNetwork takes |
behaviour |
walk() |
One behaviour or a list, for every bot |
drops |
0 | Connections dropped at seeded random ticks, to exercise reconnects |
maxMispredictions |
10 | Mispredictions each bot’s predictions may have |
maxRoomState |
32 MB | Room state bytes the limits invariant allows |
run(sim) |
none | The scenario, run beside the behaviours |
invariants |
none | Functions of { tick, room, bots } run after every tick: return false or a message to fail |
Flags given to irt simulate override the script’s fields.
Behaviours
A behaviour is { name, start?(bot), tick?(bot) }: start runs once
when the bot has joined and may await, and tick runs each tick while
it’s connected. The built-ins:
walk({ turnEvery? })moves in a random direction{ dx, dy }, each -1, 0 or 1, turning about every second. With a stepper, it’s the input the bot predicts with; on an owned entity with writablexandy, the bot writes them; otherwise it sends{ dx, dy }as a message, as theirt initroom takes. A stepper whose input has another shape needs its own behaviour that callsbot.predict.chat({ every?, message? })sends{ type: "chat", text }about every 5 seconds.idle()does nothing.
Bots
| Member | Meaning |
|---|---|
player, index |
The player ID (bot-1, … unless spawn names one) and join order |
client |
The bot’s RoomClient: state, private, entities, tick, status |
random() |
The bot’s own seeded stream |
send(message), call(name, args) |
As on RoomClient; call is typed by the schema’s calls |
write(entity, values) |
Sets writable fields of an owned entity and checks they aren’t lost |
predict(stepper, { sample, find? }) |
Predicts with the stepper; find defaults to own() |
own() |
The first entity the bot owns, or else the first whose owner field is its player ID |
view(kind), messages, data |
What it sees, what it received, and room for a behaviour’s state |
wait(ticks), until(test, maxTicks?) |
Wait on the room’s ticks |
drop(), leave() |
Drop the connection, which reconnects, or leave for good |
The scenario’s sim has room (the running room, as room code sees
it), bots, tick, random(), spawn(opts?), wait, until,
crash() and log(message, data?).
The trace
The trace is JSON lines, one event each, written to
.irt/simulate/trace.jsonl or --trace <file>. A coding agent can read
the last line for the verdict and the first failure, then the lines
around that tick.
type |
Fields |
|---|---|
start |
seed, tickRate, ticks, players, behaviours, network, interest, stepper, invariants |
join, leave, drop, restored |
tick, player |
crash, stopped, note |
tick, and the reason or note |
second |
Once a second of room time: tick, players, entities, bytesDown, bytesUp |
error |
tick and the room’s log line |
failure |
invariant, tick, players, message, and entities: each with id, kind, owner, and its values in the room and on the client |
end |
passed, every invariant’s status, checks, failures, detail and first failure, firstFailure, traffic and wall |
At most 20 failure lines are written per invariant; the end line
counts them all.
{"type":"failure","invariant":"visibility","tick":81,"players":["bot-1"],"message":"bot-1 sees runner 3, which its interest excludes at tick 81","entities":[{"id":3,"kind":"runner","owner":"bot-2","room":{"owner":"bot-2","x":0,"y":0},"client":{"owner":"bot-2","x":0,"y":0}}]}
From code, simulate(sim, { trace }) from @irt/simulate runs the same
simulation and resolves to the result the summary is printed from.