Game API
The engine exposes one control surface: MCP over the official Python SDK’s stateful Streamable HTTP transport at http://127.0.0.1:<port>/mcp. There is no REST or WebSocket gameplay surface. Ordinary HTTP serves only the browser display, health, and a read-only snapshot. serve prints the MCP URL, the display URL, and the run-log directory at startup. For the game’s objective, layers, and mechanics see the overview.
Connecting
- One MCP session is one controller. A session may span several HTTP requests; they share one session identity and count as one controller.
- Newest connection wins. Every successful MCP initialization takes a process-wide controller lease, displacing any previous holder. There is no busy rejection and no server restart is ever needed to clear a stale controller.
- Displacement archives the displaced controller’s active run as
aborted:displacedand advances no simulated time. The new controller starts in the lobby and inherits nothing. - Displaced sessions get
CONTROLLER_DISPLACEDon every tool call. The remedy is to reconnect, which takes the lease back. - Restart anytime. Reconnecting is always allowed; it just starts fresh in the lobby.
- No resume. Session termination or transport loss archives the active run as aborted and releases the lease. There is no resume, no retry-across-disconnect, and no session-resume token. A lost response is recovered by calling
statusand reconciling within the same live session. - On process restart, any unfinalized run log is finalized as
aborted:process_restartbefore a new controller is accepted.
Display polling never acquires the lease and cannot reach any mutation.
Episode lifecycle
Lifecycle is lobby, running, terminal, or aborted.
initialize MCP session # acquires the controller lease
list_scenarios # lobby
observation = start_episode # -> running, tick 0
while observation.lifecycle == "running":
# inner tool loop: intake, reconciliation, map queries, dispatch
observation = next_tick(expected_tick = observation.tick)
terminate the MCP session
read the run log from the state directory
- Ticks are zero-based integers.
next_tick(expected_tick=t)commits the interval[t, t+1)and publishes stable tickt+1. - Only the participant’s outer runner calls
next_tick. Intake, reconciliation, and any inner model or tool loop must not. The server cannot tell callers apart, so this ownership is enforced by participant architecture and tests. - The episode ends
resolved(no active fires and no future ignitions) ortick_limit(reached the limit). There is no grading operation: extraction is graded offline against the published labeled datasets, and the engine never sees the participant report schema.
Observations are frozen
start_episode, next_tick, and status return the same stored observation for the current tick. Repeated reads within a tick are byte-identical: the engine never regenerates contacts or truck vision mid-tick. Pending commands are deliberately not part of the observation, so queueing a dispatch does not make a later read differ; pending summaries live on the command responses, status, and the display.
Request rules
- JSON is UTF-8, snake_case, with explicit
{"x": ..., "y": ...}point objects (never two-item arrays). - Unknown request fields are rejected to catch typos early.
- IDs are opaque nonblank strings with conservative length limits. The engine validates syntax and length but only checks IDs it owns.
incident_idis the exception: required and nonblank, then logged and echoed without any lookup (see No incident endpoints).- Rationales are nonblank plain text with a bounded length; they are never parsed as commands.
- Mutation calls carry
expected_tick. A stale or future tick is rejected, never guessed. - Requests carry no client command ID; the engine assigns a run-unique
command_id. A retried dispatch is just a new command, and per-vehicle supersession makes the retry harmless. - Every result carries
api_version: 1, andrun_id,lifecycle, andtickwhere applicable. - There are no wall-clock times in game payloads.
Tools
Errors below are the machine code; see the error catalogue.
list_scenarios
Available after connection. Returns public catalogue entries only (no truth, occupants, or tuning). Each scenario_id is a <template>@<map> pair, so the catalogue is every stage template instantiated against every packaged map.
Request:
{}
Response:
{
"api_version": 1,
"lifecycle": "lobby",
"run_id": null,
"tick": null,
"scenarios": [
{
"scenario_id": "stage1-basic@londone",
"title": "Structured reports from the call centre",
"difficulty_stage": 1,
"map_id": "londone",
"tick_limit": 100,
"default_seed": 51001,
"public_description": "The call centre hands you clean, structured reports. Two engines are on the board and the shift is yours to run."
}
]
}
start_episode
Mutating; available whenever you hold the lease. seed is optional (defaults to the scenario’s published seed). Starting while a run is live first archives that run as aborted:replaced; rerunning the same scenario and seed reproduces the same episode under a new run_id. Safe to retry: a lost response is recovered by status or by calling start_episode again.
Request:
{"scenario_id": "stage1-basic@londone", "seed": 51001}
Response:
{
"api_version": 1,
"lifecycle": "running",
"run_id": "run-8f2a",
"tick": 0,
"observation": {
"observation_id": "run-8f2a:0",
"tick": 0,
"lifecycle": "running",
"tick_limit": 100,
"terminal_reason": null,
"contacts": [
{
"contact_id": "contact-017",
"source_type": "public_call",
"occurred_at": 0,
"delivered_at": 0,
"reported_location": {"kind": "building_id", "building_id": "l41"},
"payload_type": "structured_report",
"payload": {
"claim": {
"incident_type": "fire",
"severity": {"value": "high", "confidence": 0.7},
"headcount": {"value": 2, "qualifier": "possibly_trapped", "confidence": 0.4},
"event_time": {"tick": 0, "confidence": 0.8},
"source_reliability": "medium",
"evidence": ["smoke"],
"negations": []
}
}
}
],
"vehicles": [
{
"vehicle_id": "truck-1",
"label": "Engine 1",
"position": {"x": 700.0, "y": 310.0},
"status": "idle",
"route": null,
"destination": null,
"incident_id": null,
"last_rationale": null
}
]
}
}
Stage 2+ human contacts arrive as payload_type: "human_text" with the exact reported_location alongside; the canonical claim is withheld:
{"payload_type": "human_text", "payload": {"text": "Thick smoke at the Old Market <l41>, maybe people inside."}}
Truck sensors arrive as payload_type: "truck_observation": one observation per truck each tick listing every building within radius, in (distance, building_id) order, with the exact observed status. For a burning building it also carries derived tick countdowns ticks_to_extinguish and ticks_to_collapse (whichever the fire is heading toward under current suppression; the other is null); both are null for non-burning buildings. Sensors are exact and never pass through the language renderer:
{"payload_type": "truck_observation", "payload": {"observer_id": "truck-1", "observer_position": {"x": 700.0, "y": 310.0}, "tick": 3, "radius": 80.0, "buildings": [{"building_id": "l41", "status": "burning", "ticks_to_extinguish": null, "ticks_to_collapse": 12}, {"building_id": "l42", "status": "normal", "ticks_to_extinguish": null, "ticks_to_collapse": null}]}}
status
Available after connection. Reports lifecycle, run/scenario/map IDs when public, current tick and tick limit, pending command summaries, and the current frozen observation when running or terminal. While running it omits score, hidden state, canonical claims, and the future schedule. At terminal it adds terminal_reason and outcome.
Request:
{}
Terminal response (excerpt):
{
"api_version": 1,
"lifecycle": "terminal",
"run_id": "run-8f2a",
"tick": 22,
"tick_limit": 100,
"scenario_id": "stage1-basic@londone",
"map_id": "londone",
"terminal_reason": "resolved",
"pending": [],
"observation": {"observation_id": "run-8f2a:22", "tick": 22, "lifecycle": "terminal", "tick_limit": 100, "terminal_reason": "resolved", "contacts": [], "vehicles": []},
"outcome": {
"terminal_reason": "resolved",
"casualties": 0,
"buildings_lost": 0,
"tokens": null,
"tokens_note": "participant-reported; the engine never measures tokens"
}
}
tokens is always null: the engine never measures it. What your runner reports on next_tick does not change that - the engine stores those numbers for the wall and never reads them back into the game. casualties and buildings_lost appear only at terminal.
building_to_coords
Running or terminal. Converts one public building ID (l<integer>, learned only from contacts) to its exact public point. Unknown IDs return UNKNOWN_BUILDING. The conversion is one-way: no operation resolves coordinates back to a building, and there is no name, alias, search, radius, or enumeration query. It exists to force one design decision: whether the intake model calls it as a tool or deterministic reconciliation converts IDs afterward.
Request:
{"building_id": "l41"}
Response:
{"api_version": 1, "lifecycle": "running", "run_id": "run-8f2a", "tick": 4, "building_id": "l41", "point": {"x": 713.8, "y": 303.1}}
travel_time
Running or terminal. from and to are each exactly one of {"vehicle_id": ...} or {"point": {...}}. A vehicle uses its exact current point; a point snaps to the nearest routable edge; convert building reports through building_to_coords first. Read-only: it reserves no route and queues no command.
Request:
{"from": {"vehicle_id": "truck-1"}, "to": {"point": {"x": 714.2, "y": 302.9}}}
Response:
{
"api_version": 1,
"lifecycle": "running",
"run_id": "run-8f2a",
"tick": 4,
"distance": 214.6,
"eta_ticks": 3,
"edges": ["e12", "e13", "e40"],
"polyline": [{"x": 700.0, "y": 310.0}, {"x": 714.2, "y": 302.9}]
}
dispatch
Mutating; running only. Sends one vehicle to a world-coordinate destination; there is no building destination, so convert building reports through building_to_coords first. incident_id and rationale are required and nonblank. The engine validates the vehicle, bounds, snap distance, and route, and returns the requested and snapped points plus the route summary. State changes take effect atomically at next_tick. Dispatching a moving or firefighting truck is a retarget; a later command for the same vehicle in the same tick supersedes this one. There is no recall: repositioning or standing a truck down is an ordinary dispatch to a point.
Request:
{
"expected_tick": 4,
"vehicle_id": "truck-1",
"incident_id": "participant-incident-9",
"destination": {"x": 714.2, "y": 302.9},
"rationale": "Reliable alarm and two callers report people inside."
}
Response:
{
"api_version": 1,
"lifecycle": "running",
"run_id": "run-8f2a",
"tick": 4,
"accepted": true,
"command": {
"command_id": "cmd-11",
"vehicle_id": "truck-1",
"incident_id": "participant-incident-9",
"rationale": "Reliable alarm and two callers report people inside.",
"requested": {"x": 714.2, "y": 302.9},
"snapped": {"x": 713.8, "y": 303.1},
"route": {"remaining": 214.6, "total": 214.6, "eta_ticks": 3, "edges": ["e12", "e13"], "polyline": [{"x": 700.0, "y": 310.0}, {"x": 713.8, "y": 303.1}]}
},
"pending": [
{"command_id": "cmd-11", "vehicle_id": "truck-1", "incident_id": "participant-incident-9", "rationale": "Reliable alarm and two callers report people inside.", "destination": {"requested": {"x": 714.2, "y": 302.9}, "snapped": {"x": 713.8, "y": 303.1}}, "eta_ticks": 3, "status": "accepted", "superseded_by": null}
],
"superseded": []
}
superseded lists any earlier same-vehicle command IDs this call replaced. The engine never resolves a destination back to a building; whether an arrived truck fights a fire is decided by the suppression proximity rule in the overview.
next_tick
Mutating; running only, and owned exclusively by the outer runner (see Episode lifecycle). Applies pending commands and advances the simulation exactly one tick. A stale expected_tick returns TICK_MISMATCH with the current tick and never advances; recover through status.
Request:
{"expected_tick": 4}
tokens is optional and display-only: your running provider-reported totals per role, for the wall display. It changes nothing about the tick, is never measured, never verified, and never scored - the engine stores what you send and shows it. Omit it and the tick is identical, which is what you do when playing by hand, since you have no such number to report. Each role defaults to zero, and a tick that omits tokens leaves the previous totals standing rather than resetting them.
{
"expected_tick": 4,
"tokens": {
"intake": {"read": 4900, "write": 310, "cache_read": 3100},
"dispatch": {"read": 51900, "write": 2900, "cache_read": 37000}
}
}
Response:
{
"api_version": 1,
"lifecycle": "running",
"run_id": "run-8f2a",
"tick": 5,
"committed_commands": [
{"command_id": "cmd-11", "vehicle_id": "truck-1", "incident_id": "participant-incident-9", "rationale": "Reliable alarm and two callers report people inside.", "destination": {"requested": {"x": 714.2, "y": 302.9}, "snapped": {"x": 713.8, "y": 303.1}}, "eta_ticks": 3, "status": "accepted", "superseded_by": null}
],
"events": [{"type": "arrival", "vehicle_id": "truck-1"}],
"observation": {"observation_id": "run-8f2a:5", "tick": 5, "lifecycle": "running", "tick_limit": 100, "terminal_reason": null, "contacts": [], "vehicles": []},
"terminal_reason": null,
"outcome": null
}
events carries arrivals and public vehicle changes only; suppression, intensity, collapse, ignition, and sensor events stay in the run log. On the terminal tick, terminal_reason and outcome are populated as in status.
No incident endpoints
The incident store is participant code; the simulator hosts no beliefs. Filed incidents are agent beliefs the simulator never sees, so there are no incident endpoints. dispatch requires incident_id and logs and echoes it in truck state and the run log, but never validates it against any store. Pick your own opaque convention.
Error catalogue
Tool errors carry a stable machine code; messages may improve without breaking clients. Internal failures never leak tracebacks, paths, run-log contents, or hidden state.
| Code | Meaning | Retry |
|---|---|---|
INVALID_REQUEST |
Schema, bounds, or unknown-field error | No; fix the request |
CONTROLLER_DISPLACED |
A newer MCP session took the lease | No; reconnect to reacquire |
EPISODE_NOT_RUNNING |
Operation requires a running episode | No |
TICK_MISMATCH |
expected_tick differs from the current tick |
Yes, after status |
UNKNOWN_VEHICLE |
Engine-owned vehicle ID absent | No |
UNKNOWN_BUILDING |
Public building ID absent | No |
INVALID_DESTINATION |
Point outside bounds or snap distance exceeded | No |
NO_ROUTE |
Valid endpoints are disconnected | No for the same endpoints |
COMMAND_REJECTED |
Lifecycle or state-specific command rule failed | Depends; details say why |
DATA_INVALID |
Packaged map/scenario inconsistency | No; operator defect |
INTERNAL_ERROR |
Unexpected server failure; state not committed unless the result says so | Yes |
Run logs are files
There is no export or download operation. Each run is one append-only JSONL file in the runtime state directory (serve prints the path). Students read the files directly. The finalized log includes truth, canonical claims, and extraction labels in its terminal record, and never includes participant model credentials.
Display and HTTP routes
The server binds to loopback only; there is no remote-serving or token option. These routes are for spectators and health checks, not gameplay.
| Route | Purpose | Data policy |
|---|---|---|
GET / |
Browser display shell | Static |
GET /assets/... |
Hashed static JS and CSS | Static |
GET /api/v1/maps/{map_id}/display.svg |
Sanitized curated display SVG | Public geometry only |
GET /api/v1/maps/{map_id}/buildings.json |
Public building directory (ID, name, point) | Public geometry only; never occupants |
GET /healthz |
Liveness and engine revision | No episode details |
GET /api/v1/snapshot |
Read-only full display snapshot | Scenario policy: public_only or full_state |
Mount /mcp |
MCP Streamable HTTP control surface | Controller only |
The display is polled, not pushed: the browser fetches /api/v1/snapshot on a short interval and re-renders when display_revision increases. The snapshot schema is chosen by the immutable scenario display policy: public_only redacts the hidden truth block, while full_state adds current building, fire, occupant, and casualty state (including an ever_burned flag per building). Both policies carry scenario_id, a live metrics block (casualties, buildings_lost), and the participant-reported tokens block (per role, as last sent to next_tick) - a scoreboard for the room, on a surface the agent never reads. Both policies also always carry vision_radius (the truck vision radius, for drawing view rings) and a fire_eta block of per-fire ticks_to_extinguish / ticks_to_collapse countdowns for every fire currently within some truck’s vision (the direction a fire is not heading toward is null). Each pending entry additionally carries its command’s full route polyline so the display can draw an accepted-but-uncommitted dispatch dashed before it commits. These are display-route fields, truth-gated exactly as the rest of the snapshot; the snapshot carries no building points (the display joins it against buildings.json) and never adds future events, canonical claims, or controller internals.
WSL2 users: the display URL is normally reachable from a Windows browser via localhost forwarding. If it is not, forward the port with the VS Code Ports tab.
All examples above use synthetic data and never embed credentials.