# 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`](/docs/guides/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:

```ts
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.

```ts
// 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;
    }
  },
});
```

```ts
// 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 }),
  },
});
```

```ts
// 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`](/docs/dev-tools) 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.

```json
{"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.
