Scenarios

A scenario is a TypeScript file next to your room. It says how many bots to run, what they do, and what must be true afterwards. The assertions run against a recorded authoritative timeline, one frame per tick of the server’s own state, so a scenario can prove something about a race that no client could observe.

Use a scenario when you want your own claims on top of the built-in checks. testRoom checks handler logic in process, and irtio simulate checks the built-in invariants over real sockets.

A complete scenario

Two bots grab the same object on the same tick. Exactly one of them may end up holding it.

// irtio/scenario.ts
import { defineScenario } from '@irtio/bots';

import type { schema } from './schema.js';

export default defineScenario<typeof schema>({
  bots: 2,
  seconds: 4,
  seed: 41,

  script: async (bot) => {
    // The room opens a gate once both bots are present, so both calls go out together.
    await bot.until(() => bot.room.state.gate.open === 1, { label: 'the gate' });
    await bot.room.call.grab();
    await bot.wait(500);
  },

  assert: (timeline) => {
    const last = timeline.at(timeline.ticks[timeline.ticks.length - 1]!);

    timeline.check('exactly one grant', () => {
      const attempts = last.attempts;
      const granted = attempts
        .ids()
        .map((id) => attempts.get(id)!)
        .filter((row) => row.granted === 1);
      if (granted.length !== 1) {
        throw new Error(`required exactly one granted grab, saw ${granted.length}`);
      }
    });

    timeline.check('the holder never changed', () => {
      const holders = new Set<string>();
      for (const tick of timeline.ticks) {
        const holder = timeline.at(tick).objects.get('O')?.holder;
        if (holder !== undefined && holder !== '') holders.add(holder);
      }
      if (holders.size !== 1) throw new Error(`required one holder, saw ${holders.size}`);
    });
  },
});

script is the ordinary bot script from @irtio/bots: the same bot object, the same bot.room, the same until and wait. seconds and seed are optional and default to 10 and 0x17710.

Adversarial scenarios

Three more optional fields make a scenario hostile. Each has a flag on irtio simulate too, and the scenario wins when both are given, so a flag cannot change what a scenario tests.

fieldwhat it does
conditionsNetwork conditions injected into every bot, or a function of the bot index. Keys are rttMs, jitterMs, loss, duplicate, reorder, reorderMs.
cheattrue for every bot, or a function of the bot index, for bots that write play-illegal values.
truthtrue to save the room at the end and diff it against what each client received.

A function of the index is why per-bot splits belong here rather than on the command line. A lagged shooter against a clean target is one line:

conditions: (index) => (index === 0 ? { rttMs: 200 } : undefined),

assert receives a second argument alongside the timeline:

assert: (timeline, evidence) => {
  timeline.check('the lagged shooter missed', () => {
    const worst = Math.max(...evidence.hits.map((h) => h.missDistance ?? 0));
    if (!(worst > 0.5)) throw new Error(`required a miss, the worst was ${worst}`);
  });
},

evidence.hits holds one row per bot.shot(...), correlated against the recording: the tick the shot was judged at, the target’s authoritative position at that tick, and the distance between that and where the shooter aimed. That tick is exact when the shot carried a serverTick your room reported, and estimated from the injected latency when it did not; row.estimated says which, and row.rewound says whether the room used lag compensation to decide. evidence.truth holds the save diff when truth asked for one. Both are described in Invariants and load runs.

Running it

irtio dev                                 # in one terminal
irtio simulate --scenario irtio/scenario.ts
Exit codeMeaning
0Every assertion held and every built-in invariant held
1Something measured failed
2The run never happened, for example the scenario file did not compile, so the run says nothing about your room

From an agent, the same run is the scenario_run tool on the irtio MCP server. It takes the scenario path and reports every assertion with the tick it read.

Both write two files beside each other: the frame trace and the recorded timeline as JSON. The timeline file lets you re-read a failed run without running it again. Its layout is not a stable format and may change between releases.

The timeline API

timeline is assert’s first argument. The second, evidence, is described under “Adversarial scenarios” above.

memberwhat it does
timeline.ticksEvery recorded tick, oldest first.
timeline.at(tick)The room’s state at that tick. Throws if that tick was not recorded.
timeline.find(match)The first { tick, state } where match(state, tick) is true.
timeline.check(name, fn)Runs one named assertion. A throw inside it fails the scenario.
timeline.droppedTicks the recorder’s cap evicted. Above zero, the recording is a tail.
timeline.roomIdThe room the recording came from.

A state is keyed by your schema’s own collection names, plus the built-in clients presence collection every room records. Which accessors a collection offers depends on its shape:

memberforwhat it does
get(id)entityThat entity’s fields at this tick, or undefined if it was not there.
has(id)entityWhether the entity was there.
ids()entityThe ids present at this tick.
sizeentityHow many.
owner(id)entityThe client that owned it, or undefined for server-owned state.
valuesingletonThat singleton’s fields at this tick.

Read a singleton through value, and an entity collection through get(id). Using the other shape’s accessor throws and names the one that fits, so match.get('anything') on a singleton fails at the read rather than handing you a blank to assert on:

const round = timeline.at(tick).match.value.round;

Nothing here returns a quiet blank. A tick the recorder never held, or dropped at its cap, throws and names the range that survived. A collection name your schema does not have throws and names the ones it does.

The state is typed by the schema you pass to defineScenario<typeof schema>: each collection is a property, rows carry the schema’s field types, and the built-in clients presence collection is there too. A misspelled collection name, or the other shape’s accessor, is a compile error. Without a schema type argument the state is an index signature over Record<string, unknown> rows, so under noUncheckedIndexedAccess each collection is possibly undefined and you write last.attempts!; at runtime a name the room does not have still throws with the real names.

What runs underneath

A scenario run is a full irtio simulate run. Every built-in invariant is checked over the same sockets and still gates the exit code, so a scenario that passes on a room leaking state to the wrong role still fails. Your assertions are added to the report rather than swapped in for it.

Two things to know

Recording starts once every bot has joined. The ticks a room spent coming up are not in the timeline, so write assertions against timeline.find(...) or against timeline.ticks, rather than against a tick number you counted from zero.

Replay re-runs the room. Assertions over a recording are exact: the same recording always gives the same verdict. Running the scenario again re-runs the room, so a handler that reads Date.now() or Math.random() can produce a different timeline and a different verdict. Put seeds and timing into synced state if you want a scenario that replays.

Limits

The recording is per tick. Two writes to the same field inside one tick leave only the second one visible, so a room that granted two conflicting requests in one tick still shows one holder at every recorded tick. Record the decision itself, the way the example records granted, when you want to assert on arbitration.

Scenarios run against irtio dev. The local dev server is the only thing that serves the authoritative timeline and the room saves the truth seam reads. A deployed project has no timeline tap, so --scenario, scenario_run, hit registration and truth all point at a room on your own machine.

The recording is capped. 3600 ticks and 200000 entity records, oldest evicted first. A run that hits either cap says so in the report, and timeline.dropped counts what went.