WebMCP landed in Chrome behind a flag and almost nobody at work had seen it. The pitch — a web page can declare tools that an AI agent calls directly — sounds abstract until you watch it happen. Slides weren't going to do it.
So the goal was never "build a product". It was: make the mechanism visible in a room full of people in under five minutes. That single constraint decided everything. If a choice made the app more useful but the mechanism less obvious, the mechanism won.
Games turned out to be the right vehicle, for a reason we didn't expect at the start: a game has rules the page can enforce. When an agent tries to move out of turn and the page answers { ok: false, reason: "it's not your turn" }, everyone in the room understands the trust model instantly. You can't get that from a to-do list demo.
What it does
Duel is a four-game arcade in the browser where your opponent is an external AI agent — and the agent doesn't see pixels. It plays by calling tools the page registers with document.modelContext.registerTool().
- Minesweeper duel — a shared 9×9 board, 13 mines, turn-based. Claim a mine correctly and you keep playing.
- Connect 4 — turn-based, or a one-shot puzzle in single player.
- Pong — real-time. Yes, real-time, over a request/response protocol. More on this below.
- Towers of Hanoi — both sides race their own tower against one clock. Five discs, so $2^5 - 1 = 31$ moves is optimal.
Nothing leaves the tab. No backend, no API key, no LLM call from the app. The page only registers tools; the intelligence is entirely on the other end of the wire. That's the whole point — the page is the tool provider, not the AI.
The design exists to make three moments land:
- The page announces itself. Open the inspector and the tools are there with names and schemas. Nobody guessed anything.
- The agent plays, visibly. Every call lands live in the right-hand rail with its arguments and its response. That rail is what turns a game into a WebMCP demo.
- The tools change on their own. Switch games and an AbortController unregisters the old game's tools and registers the new one's. The inspector's list transforms live.
How we built it
Vite + TypeScript, no framework, no backend. Every module stays under ~200 lines, which is why each game is split into state / actions / query / render / tools. A dist/ you can serve from anywhere.
The interesting engineering isn't the games — it's tool design, and three rules were earned the hard way:
Give the agent constraints, not drawings. A tool that dumps the board makes the agent do vision work in text. ms_frontier hands it the deduction already worked out: these cells are provably safe, these are provably mines. c4_analysis does the same. Without those the agent plays badly and the demo falls apart. They are the real work of the project.
The description is the code. When the agent plays badly, the fix is almost always in the description string, not the logic. We edited prose far more often than we edited functions.
Stop the help short of solving it. Hanoi's hanoi_moves returns the legal moves and says outright that they are not the good ones. The optimal move is a four-line recursion — a tool that returned it would leave the agent nothing to do but transcribe, and the race would demonstrate nothing.
Pong, the part that shouldn't work
A tool call is request/response, and an agent round-trip takes seconds — longer than the ball takes to cross the court. Polling is hopeless.
The answer was to make pong_read a blocking tool. It parks until the ball turns toward the agent, then returns the interception point. The agent's ordinary call-and-respond rhythm becomes the rally.
The interception is computed analytically, because an agent reasoning about wall bounces from raw $v_x, v_y$ gets it wrong often enough to lose every point. Free flight to the paddle face:
$$y_{\text{free}} = y_0 + v_y \cdot \frac{\Delta x}{|v_x|}$$
then folded back into the court by unrolling the bounces — a triangle wave over the span $s = h - 2r$:
$$y_{\text{hit}} = r + \Lambda_s!\left(y_{\text{free}} - r\right), \qquad \Lambda_s(k) = \begin{cases} k' & k' \le s \ 2s - k' & k' > s \end{cases}, \quad k' = k \bmod 2s$$
And while the call is out, the page slows the ball to 12% speed. That pause is a round-trip to the agent, made visible — it's the moment the room actually sees what a tool call costs. The ball's speed isn't a playability number, it's a budget:
$$t = \frac{\Delta x}{\sigma \cdot v}, \qquad \sigma = 0.12$$
There's a unit test asserting that budget stays above 8 seconds. If someone tunes a speed and that test goes red, the demo is what broke.
Challenges we ran into
The agent grabbed the human's mouse. Live. An agent whose client didn't surface the tools fell back on screenshot-and-click and started dragging our paddle around. Two fixes: Pong's paddle is now keyboard-only — keys are the human's channel, tools are the agent's, and the separation is the point — and clicking the board on the agent's turn no longer fails silently. It flashes the turn box and leaves a rejected line in the rail, because a silent swallow leaves both the agent and the room with no idea why nothing is happening.
A hidden tab froze the game. The agent often drives from another window, so the tab is hidden exactly when the game most needs to run. Chrome throttles main-thread timers in a background tab to about one per second. We measured it — 5 seconds, setInterval(50ms):
┌─────────────────┬─────────────┐ │ Where │ Ticks in 5s │ ├─────────────────┼─────────────┤ │ Main thread │ 6 │ ├─────────────────┼─────────────┤ │ Inside a Worker │ 103 │ └─────────────────┴─────────────┘
So Pong's heartbeat moved into a Web Worker. Hanoi dodged the same class of bug differently: its clock is a timestamp, not a tally, so throttling can stutter the display but never slow the race.
The bugs that only exist in the loop. Four separate Pong bugs passed every per-mechanism unit test and only appeared with the whole read → think → move → read cycle running at realistic latency. A shot could turn, arrive, and bounce entirely inside the gap between the agent's pong_move and its next pong_read — nothing parked to wake, so the trigger looked broken. There's now a test that drives the full loop across a sweep of simulated agent latencies, because the bugs it catches are properties of the sequence, not of any one function.
The modal that made the agent give up. This one stung. On boot, the page used to ask the human "play vs agent, or solo?" — honest, because the WebMCP API has no "a consumer is attached" signal at all. But it registered nothing until someone clicked, and an agent attaching to the tab lists the page's tools immediately. It got an empty list, concluded there was nothing to call, and fell back to screenshots — the exact failure the whole demo argues against. We had asked the right question at the wrong moment. Now detection means duel mode with tools up first, and the human's escape hatch is a dropdown in the top bar. The honesty moved to after registration: the pill reads N tools · waiting for agent until a call actually arrives.
The page that grew to 3907 pixels. The call rail gains a line per tool call. Nothing bounded it, so during a long Pong rally the page stretched past 3900px — which scrolled the board off the top of the screen while the agent was playing on it. The layout is now an app shell: board pane and rail scroll independently, the page itself never does.
Accomplishments we're proud of
Real-time Pong over a request/response tool protocol, with the latency turned into the most legible part of the demo instead of the thing you apologize for.
Deduction aids good enough that the agent plays well — the demo lives or dies on this, and it's invisible when it works.
141 tests, and the discipline of writing a regression test at the level the bug actually lived at.
What we learned
- A modal is a gate, and the agent is on the other side of it. Anything an agent needs must exist before a human interacts with the page.
- "Tools registered" and "an agent is playing" are different facts. The API can only tell you the first. Say so out loud, or a page that failed and a page nobody is calling look identical from the back of the room.
- Test the viewport the demo is actually watched in — inside the agent's own browser window, which is narrower and shorter than a desktop tab.
- Prose is load-bearing. Every Pong response ends with a next_action field naming the exact call to make next. Without it the agent answers one read, writes its user a progress report, and its paddle stands still for the rest of the round.
- Two things about the current Chrome that cost us an afternoon each and aren't documented anywhere: executeTool wants its arguments as a JSON string, not an object, and a subframe shares the top-level tool registry — an iframe of the page took the tool list from 8 tools to 16, duplicate names accepted.
What's next
Making the deduction aids adjustable live, so the room can watch the agent's play degrade as you take its constraints away — the clearest possible argument for why tool design is the actual work.
Built With
- anime.js
- html5
- typescript
- vite
- vitest
- web-workers
- webmcp
Log in or sign up for Devpost to join the conversation.