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.
| 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
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 code | Meaning |
|---|---|
0 | Every assertion held and every built-in invariant held |
1 | Something measured failed |
2 | The 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.
| 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, plus the built-in clients presence
collection every room records. Which accessors a collection offers depends on its shape:
| member | for | what it does |
|---|---|---|
get(id) | entity | That entity’s fields at this tick, or undefined if it was not there. |
has(id) | entity | Whether the entity was there. |
ids() | entity | The ids present at this tick. |
size | entity | How many. |
owner(id) | entity | The client that owned it, or undefined for server-owned state. |
value | singleton | That 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.
Related
- Invariants and load runs for the layer underneath
- Simulated players for in-process handler tests