Scenarios
A scenario is a TypeScript file next to your room: how many bots to run, what they do, and what must be true afterwards. The assertions do not run against a live room. They 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. Same seed, same room, same verdict.
Scenarios are the second layer of testing. testRoom checks
handler logic in process, irtio simulate checks the built-in
invariants over real sockets, and a scenario adds your own claims on top of both.
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<unknown>();
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: a scenario is the thing under test, and a flag must not
quietly change what it is testing.
| field | what it does |
|---|---|
conditions | Network conditions injected into every bot, or a function of the bot index. Keys are rttMs, jitterMs, loss, duplicate, reorder, reorderMs. |
cheat | true for every bot, or a function of the bot index, for bots that write play-illegal values. |
truth | true 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
server was on when the shot arrived, the target’s authoritative position at that tick, and the
distance between that and where the shooter aimed. 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 0 means every assertion held and every built-in invariant held. Exit 1 means something measured failed. Exit 2 means the run never happened, for example the scenario file did not compile, and 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 is there so you can 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.
| member | what it does |
|---|---|
timeline.ticks | Every 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.dropped | Ticks the recorder’s cap evicted. Above zero, the recording is a tail. |
timeline.roomId | The room the recording came from. |
A state is keyed by your schema’s own collection names. Each collection offers:
| member | what it does |
|---|---|
get(id) | That entity’s fields at this tick, or undefined if it was not there. |
has(id) | Whether the entity was there. |
ids() | The ids present at this tick. |
size | How many. |
owner(id) | The client that owned it, or undefined for server-owned state. |
value | A singleton’s fields. undefined for an entity collection. |
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. If you are running with noUncheckedIndexedAccess, TypeScript will type each
collection as possibly undefined because your schema is not known to these types; at runtime it
never is. That is why the example above writes last.attempts! and objects?.: the ! and ?. satisfy the compiler, and a misspelled collection name still throws with the real names instead
of passing on an empty read.
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, not 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 authoritative timeline is served by the local dev
server only, and so are the room saves the truth seam reads. A deployed tenant 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.
Next steps
- Invariants and load runs for the layer underneath
- Simulated players for in-process handler tests
- Testing in CI for wiring both into a push