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 writable x and y, the bot writes them; otherwise it sends { dx, dy } as a message, as the irt init room takes. A stepper whose input has another shape needs its own behaviour that calls bot.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.

View this page as Markdown