A turn-based stealth roguelite built to be played by a human and an AI agent at the same time, over WebMCP.
You play the operative on the ground. Your handler is an AI agent wired in through
document.modelContext, and it never touches your controls. It works the building instead:
sweeping the network, looping camera feeds, blowing speakers to drag patrols off your path,
slamming blast doors in a guard's face, rewriting patrol orders. You move through the gaps it opens.
| Live demo | https://OkeyAmy.github.io/nullrun/ |
| Demo video | https://youtu.be/IWgT0eSfDL4?si=Jrg9X4M9svlMfQoI |
| Licence | MIT |
| Tool surface | 18 tools on document.modelContext |
Most "AI-enabled" web apps hand the agent the same buttons the user already has. NULLRUN gives it a different set of verbs, paid for out of its own resource pool, so the two roles complement each other instead of overlapping.
| Operative (human) | Handler (agent) | |
|---|---|---|
| Resource | 3 action points / turn | 4 bandwidth / turn |
| Can do | move, sprint, hack terminals, lift intel, extract | ping the network, loop cameras, seal doors, cut power, reroute patrols |
| Cannot do | see through walls, touch the grid | physically move, hack a terminal, carry anything |
Neither side can finish a floor alone. The agent is a second player class, not an accessibility layer bolted onto the first.
The obvious objection to a game submission is that agents have no reason to play one. NULLRUN answers that by giving the agent work the human physically cannot do, in a format that suits how WebMCP actually behaves.
Turn-based is the integration, not a style choice. Every tool call is a network round trip, realistically 1–3 seconds. Put that in a real-time game and it reads as lag; the agent becomes a sluggish side channel nobody wants to use. Put it in a turn-based tactical game and the same call becomes an ordinary game action, with latency that simply stops mattering. The genre followed from the transport.
Information asymmetry drives the whole thing. Your operative sees 8 tiles. The agent sees whatever it has pinged. Because neither party holds the full picture, a handler is worth having and the tools have something real to say.
The shape generalises. Drop the fiction and what remains is a template for any app where an agent should hold context, watch state and act on the system while a person works the surface: alert-watching dashboards, ops consoles, incident response, logistics. A game just makes that division of labour legible in a way a chat sidebar never does.
Read tools hand back reasoning material, not pixels. plan_route returns more than a path. You
get the risk attached to each step, the name of the guard or camera watching that step, and where
cover sits:
{
"length": 7, "apRequired": 7, "exposedSteps": 2,
"mostDangerousStep": { "x": 14, "y": 9, "risk": 18, "watchedBy": ["guard-1", "cam-0"] },
"steps": [ { "order": 1, "x": 9, "y": 9, "risk": 0, "watchedBy": [], "cover": true } ]
}An agent can act on that. No agent can act on a screenshot. The same numbers feed the human's hover preview, so both players read from one truth.
Agents skip the interface entirely. The opening screen and briefing exist for humans. If a
handler calls a gameplay tool on a cold page, the run simply starts — ping_scan on a fresh load
works. Read tools carry no side effects, so get_situation never kicks anything off, and a human
who already picked co-op keeps that choice.
Tools show up only when they can be used. begin_infiltration lives in the briefing and
vanishes the moment a floor starts. choose_implant exists while a choice is pending, and
start_new_run only once a run is over. No dead options cluttering the agent's menu.
Costs are introspectable. list_abilities reports what every action costs right now and whether
it is affordable, reading from the balance table the rules use. What the agent is told cannot drift
from what the engine does.
Failures name their own fix. "No walkable route to 14,9. A sealed door may be in the way — try cycle_door." Every error is written for a reader who has to pick a next move.
Control changes hands mid-run. set_play_mode works at any point, so "you take over" lands
halfway through a floor rather than only at the menu.
Every result tells the agent how to keep going. A tool that only reports what happened leaves a
handler with no reason to take another turn, so it stalls after one call. Each action answers with
the live budget and a nextSteps list built from actual state — a hunting guard pushes
cycle_door to the front, an empty AP pool asks for end_turn, an undiscovered core suggests
ping_scan. Every entry names a registered tool and says why:
{
"actionPoints": 3, "bandwidth": 3, "alarm": 6, "turn": 1,
"continue": "The run is still live — keep going by calling ping_scan.",
"nextSteps": [
{ "tool": "ping_scan", "why": "No data core discovered yet. Sweep unexplored areas to find one before moving blind." },
{ "tool": "move_operative", "why": "3 AP left. Prefer a destination whose risk is 0 — ending a turn inside a cone is how runs end." }
]
}One engine serves both players. Each tool calls the same method its UI button calls. No agent-only code path exists to quietly drift from what a human sees.
Read — free, side-effect free
| Tool | What it gives an agent |
|---|---|
get_situation |
Full tactical snapshot: position, resources, alarm, discovered guards with facing and alert state, cameras, objectives, plus playMode and a yourRole sentence |
assess_tile |
Terrain, walkability, cover, and exactly which observers hold that tile |
plan_route |
Path with per-step risk, named watchers, AP cost, worst step |
list_abilities |
Live cost of every action and whether it is affordable right now |
get_run_status |
Floor, implants, intel this run, banked intel, end reason |
Operative — costs action points
| Tool | Cost |
|---|---|
move_operative |
1 AP/tile, or 2 AP to sprint up to 3 tiles (noise radius 5) |
hack_terminal |
2 AP (1 with Silent Splice) |
grab_intel |
1 AP |
operate_door |
1 AP, noise radius 3 |
extract |
free |
end_turn |
free |
Handler — costs bandwidth
| Tool | Cost | Effect |
|---|---|---|
ping_scan |
1 | Reveals terrain, guards and cameras around a point; nearby guards notice the probe |
cycle_door |
1 | Opens a door, or seals it so guards cannot path through for 3 turns |
loop_camera |
2 | Blinds a camera for 4 turns |
trigger_distraction |
2 | Noise at a point; every guard in radius 8 abandons its patrol to investigate |
cut_power |
3 | Every vision cone drops to 40% range for 3 turns |
reroute_patrol |
2 | Spoofs a non-hunting guard's next waypoint |
Meta — set_play_mode (any time), begin_infiltration, choose_implant, start_new_run
// src/webmcp/bridge.ts — prefer the browser's own implementation
if (document.modelContext) return { mode: 'native', context: document.modelContext };
initializeWebMCPPolyfill(); // @mcp-b/webmcp-polyfill
return { mode: 'polyfill', context: document.modelContext };Registration passes an AbortSignal, which lets phase-scoped sets swap cleanly. A badge in the top
right of the UI shows which mode you landed in.
src/game/ simulation — config (all tuning), mapgen, vision/threat, grid, engine
src/render/ atlas manifest, palette, effects, canvas renderer
src/ui/ HUD, overlays, action bar
src/webmcp/ bridge (native → polyfill) and the tool surface
tools/ asset pipeline, browser smoke check, eval generator + runner, agent demo
tests/ engine, WebMCP surface, invariants and fuzzing
flowchart LR
H[Human] -->|clicks, WASD| UI[HUD + canvas]
A[AI agent] -->|executeTool| MC[document.modelContext]
MC --> TS[ToolSurface]
UI --> G[Game engine]
TS --> G
G --> ST[(Single state)]
ST --> R[Renderer]
ST --> TS
Both players funnel into one engine holding one state. Balance lives in src/game/config.ts, and
the tools report straight from that table.
The first screen asks who is at the controls. An agent can read that answer and set it, so a handler joining mid-run knows its role.
- Human — you move the operative while an AI handler works the network beside you. You get the briefing first, since you are the one who has to read the objective.
- AI — the agent takes both roles and runs the infiltration itself. Picking this starts the
floor outright: a spectator has no reason to click through a briefing and an agent cannot see one,
so there is no "now press begin" step to strand either of them. Human input is genuinely
switched off here: keyboard and clicks stop reaching the operative, so the two of you cannot
fight over the same body. The action bar becomes a spectator strip, and
C(or the Take control button) hands the operative straight back to you mid-floor.
Tools are published on document.modelContext, so a WebMCP-aware agent finds them on its own.
Nothing to paste, no key to configure. From the spec's
implementation status:
- ChatGPT Desktop — WebMCP is supported. Open the URL, tell it to play. No flags.
- Chrome 149+ / Edge 150+ — origin trial is live, which needs Early Preview Program enrolment plus an origin-trial token for your deployed origin.
- nekuda WebMCP Workbench — a Chrome extension that lists a page's tools and drives them with an agent, bring your own key. Fastest way to play co-op today without origin-trial enrolment.
- Brave — experimental, through Leo.
- No agent handy — pick AI and run
pnpm demo.
Five floors of a procedurally generated facility. Siphon every data core, reach the extraction pad, pick an implant, descend. Intel banks between runs.
- Guards carry vision cones and hearing, and move through three alert states: patrol, suspicious, hunting.
- Cover breaks line of sight past 2 tiles. Sprinting makes noise, and noise pulls patrols.
- Being seen raises the alarm. Hit 100% and the facility locks down with you inside.
- A hunting guard that reaches you ends the run on the spot, unless a Reflex Governor is installed.
- Sealed doors genuinely reroute pathfinding. Guard AI and route planner both respect them.
- Nine implants: extra AP, silent sprint, wider scans, cheaper blackouts, faster alarm decay, and more.
Controls: WASD/arrows move · click to path · hover to preview route risk · E hack · F lift
intel · Shift sprint · X extract · Space end turn · C take control back from the agent.
pnpm install
pnpm dev # http://localhost:5173
pnpm test # 43 tests: engine, WebMCP surface, invariants, fuzzing
pnpm smoke # drives the real page in Chromium, incl. document.modelContext
pnpm demo # a handler plays the game using only the WebMCP tools
pnpm evals:build # regenerate the 313-case eval matrix
pnpm evals # execute all 313 against the live page + a success-path pass
pnpm assets # rebuild the tile atlas from the Kenney source art
pnpm buildpnpm smoke, pnpm demo and pnpm evals want a dev server on port 5183
(npx vite --port 5183), or take a URL as their first argument.
pnpm demo is the sharpest completeness check available. A handler with only the public tool
surface, no DOM access and no engine access, sits down and plays. It pings to find cores, reads
plan_route risk, loops cameras, fires distractions, seals doors on hunters, and refuses to finish
a turn standing in a vision cone. On the default seed it clears two floors in roughly 160 tool
calls. A script that can clear floors armed with nothing but the tools tells you the surface is
complete.
T3 floor 1 · ap 3 · bw 4 · alarm 0% · cores left 1
· route 22 steps, 1 exposed, risk 7
→ cut_power (shorten every cone): Power cut for 3 turns. All vision ranges drop to 40%.
→ move_operative (toward the core, ending on risk 0): Reached 7,12 in 3 step(s).
T5 floor 1 · ap 3 · bw 4 · alarm 0% · cores left 1
· route 16 steps, 3 exposed, risk 27
→ trigger_distraction (pull guard-1 off the route): Distraction fired at 19,17; 1 guard investigating.
Past that, the suite goes wide on purpose rather than parading a few happy paths:
| Sweep | Scale |
|---|---|
| Floor invariants | 300 seeds × 5 floors — connectivity, nothing inside geometry, every core reachable |
| Fuzzed play | 200 runs × 120 random tool actions — >10,000 actions, state invariants asserted after every one |
| Resource safety | 120 runs proving AP and bandwidth can never rise mid-turn |
| Vision consistency | every tile of 40 floors: a reported watcher must have real line of sight and range |
| Blackout monotonicity | ~24,000 tile comparisons — cutting power can only ever remove watchers |
| Determinism | same seed rebuilds an identical floor; 120 seeds give near-unique layouts |
| Tool-choice evals | 313 generated cases across 18 tools (tools/build-evals.mjs) |
| Eval execution | all 313 executed against the live page via executeTool, plus a 19-check success-path pass proving every tool can succeed, not merely respond |
| Browser smoke | 21 checks on the real page: both mode entry paths, rendering, HUD survival under input, control occlusion, mode input-gating in both directions, agent tool calls, zero console errors |
Assertions were mutation-checked: the code was deliberately broken to confirm the tests notice. Where a single-line mutation slipped through, the test got strengthened until it caught the change.
- A tool aborted its own registration mid-call. A phase change fired inside
execute()cancelled the registration belonging to the very tool that triggered it, so callers received"Tool unregistered"in place of their result. Phase syncs now yield a task first. - Closed doors made objectives unreachable. Pathfinding treated every shut door as a wall, which left generated floors reporting cores and extraction as unreachable. Unlocked doors are now pathable and get opened in passing; only sealed ones block.
- The canvas painted over the action bar. A positioned wrapper let an over-tall canvas swallow
every enabled button. Containment plus a
ResizeObserverfixed it, and an occlusion hit-test now runs inside the smoke check. set_play_modevanished once playing, which meant control could never pass to an agent mid-run. The eval pass surfaced it; promoting it to a core tool fixed it.
Two of my own invariants turned out to be wrong, and the tests were corrected rather than the code: seeing a wall face is perfectly legitimate, and the fuzz sweep needed a real timeout.
Every pull request carries a TestSprite check. The GitHub App runs the project's suite in TestSprite against the pull request's deploy preview and reports the result on the PR, so the suite runs without anyone remembering to start it.
The suite drives the real page — real keyboard movement, real clicks on canvas tiles and doors, drag gestures, buttons, and the real turn cycle — and asserts on live game state: the AP economy and auto-end-of-turn, click-to-path routing, adjacent-door toggling, sprint, hacking, intel lifts, takedowns, core collection and extraction gating, floor progression, both failure states, the WebMCP tool surface, and persistence across reloads.
Two artifacts make it auditable from the repo:
testsprite/tests/— the exact Python TestSprite executes for every code-defined test, with a table mapping test ids to names.testsprite/logs/— JUnit reports and execution summaries from suite runs, carrying test ids and run ids for cross-checking in the TestSprite dashboard.
- Turn-based so tool latency stops being a problem at all (see §1).
- Asymmetric verbs so the agent never degenerates into a redundant remote control.
- Fog of war on both sides, which is what gives
ping_scana reason to exist. - A single balance table (
src/game/config.ts) feeding both the rules and the tool descriptions, so agent-facing documentation cannot drift out of sync. - An art pipeline instead of committed blobs.
tools/build-atlas.shpushes CC0 Kenney sprites through a luminance ramp into the game palette, keeping the palette reproducible and editable.
- Chrome and Edge need origin-trial enrolment. Without it the page falls back to the polyfill, which registers identical tools, so behaviour matches and only discovery differs.
- Guard AI stays deliberately readable (patrol, investigate, hunt) rather than clever. The tactical interest comes from two players splitting the work.
Environment and character art derived from Kenney "Tiny Dungeon" (CC0),
recoloured into the NULLRUN palette by tools/build-atlas.sh. WebMCP polyfill by
@mcp-b. Effects, HUD, layout, engine and
tool design are original.
Licensed MIT — see LICENSE.


