Tic-Tac-Toe MCP Server
by devniel
README.md
# tic-tac-toe over an MCP channel
Two Claude Code sessions play tic-tac-toe **against each other**, coordinating
through a shared MCP server — no human relaying moves. Each session is woken on
its turn by a Claude Code _channel_ push, plays, and goes idle until the next
push.
```
┌───────────────── the referee (one process, owns the game) ─────────────────┐
│ POST /join · POST /move · GET /state · GET /subscribe · GET /spectate │
└──────────▲──────────────────────────────────────────────────▲──────────────┘
│ HTTP + SSE │ HTTP + SSE
channel adapter (X) channel adapter (O)
stdio MCP server spawned by stdio MCP server spawned by
Claude session X Claude session O
tools: join_game/view_board/make_move (same) — pushes "your turn" in
```
Open <http://127.0.0.1:8787/> to watch it live, or read the built-in **Docs** tab
on that page for the architecture and turn-flow diagrams.
## Project layout
Organised by feature, not by layer: everything about the game lives in `game/`,
everything about the channel in `channel/`, every prompt in `prompts/`.
```
src/
game/
engine.ts pure rules — win, draw, legal moves (unit-tested)
game.ts authoritative state + transitions, announces events
serialize.ts model → wire payloads (public + spectator shapes)
routes.ts the Express router: join / move / state / reset
streams.ts every open SSE stream; routes events to the right player
prompts/ what the agent is told, as Jinja (Nunjucks) templates
channel-instructions.j2 goes into the session's system prompt
your-turn.j2 · game-over.j2 the pushed channel notifications
seed.j2 paste this into a session to start it
render.ts the tiny loader
server.ts Express app: routes, templates, static assets, listen
templates/ EJS page, split into partials
index.ejs the shell — head + includes
partials/
header.ejs title + connection indicator
tabs.ejs Board / Docs switch
panel-board.ejs the live game
panel-docs.ejs the documentation sections
diagram-architecture.ejs inline SVG
diagram-sequence.ejs inline SVG
channel/
index.ts the MCP channel: capability, tools, stdio
refereeClient.ts HTTP + SSE link to the referee (self-healing)
tools.ts join_game / view_board / make_move
notifications.ts referee event → channel notification
public/
styles/ tokens · base · board · docs
scripts/
model.js MODEL: game state + which seat this browser holds
view.js VIEW: the only module touching the DOM
controller.js CONTROLLER: intent in, model updates out
api.js fetch + EventSource, nothing else
main.js entry point
```
`game.ts` knows the rules but no transport — it announces events, and `streams.ts`
decides how to deliver them. Prompts are content, not code: edit a `.j2` file to
change what the agent is told, without touching TypeScript.
## Requirements
- **Node 20+** and **pnpm** (`pnpm install` once).
- **Channels enabled.** Channels are a Claude Code research preview.
- **Pro / Max personal account**: enabled by default. ✅ use this.
- **Team / Enterprise org** (e.g. a work account): blocked until an Owner enables
`channelsEnabled` — you'll get `blocked by org policy`. Log in with a personal
Pro/Max account instead, or have the Owner enable it.
## Run it
```bash
cd ~/dev/tic-tac-toe
pnpm install # once
# 1. start the referee (leave running)
pnpm server # listens on http://127.0.0.1:8787
```
**Open the live board** in a browser: **http://127.0.0.1:8787/** — it shows each
session joining, whose turn it is, every move, the winning line, and a live event
feed, all pushed over SSE. Leave it open while the match runs.
Then open **two** more terminals, both in this directory. In each:
```bash
claude --dangerously-load-development-channels server:tictactoe
```
- Accept the dev-channels warning ("I am using this for local development").
- Accept "New MCP server found in this project: tictactoe" → **Use this MCP server**.
- Paste the contents of **`src/prompts/seed.j2`** as your first message.
- The session calls `join_game` and goes idle. Do the same in the other terminal.
The **first** session to call `join_game` is X, the **second** is O. As soon as
both have joined, the referee wakes X — and from there the two sessions ping-pong
on their own until someone wins or it's a draw. You just watch.
A dim line under each session's banner confirms the channel is live:
`Channels (experimental) messages from server:tictactoe inject directly in this session`.
## Play against Claude yourself (human vs AI)
You can take one seat. Start the server, open **http://127.0.0.1:8787/**, and click
**🙋 Join as human** — you get whichever mark is free. Launch **one** Claude session
(`claude --dangerously-load-development-channels server:tictactoe`, then the seed) to
fill the other seat. When it's your turn the board highlights and you click an empty
cell; your move flips the turn and the Claude opponent is woken by its channel to reply.
Only **one** human seat is allowed — the point of the demo is the Claude-channel side,
so the second player is always a Claude session (a second human join is rejected).
## Try it without Claude (sanity check)
```bash
pnpm test # engine unit tests
pnpm server # then, in another shell, drive it with curl:
curl -s -XPOST localhost:8787/join -d '{}' # -> {"playerId":...,"mark":"X",...}
curl -s -XPOST localhost:8787/join -d '{}' # -> mark O; X is now woken
curl -s localhost:8787/state
```
## Notes
- One game at a time. `POST /reset` (or restart the server) starts a fresh match;
a new pair joining after a finished game also resets automatically.
- Everything binds to `127.0.0.1` only. Change the port with `TTT_PORT=9000 pnpm server`
(the channel reads the same `TTT_PORT`, or set `TTT_SERVER=http://host:port`).
- This is the same pattern as the rounders channel adapter, in miniature:
a shared event source + a per-session stdio channel that wakes an idle session.
- `pnpm format` / `pnpm format:check` run Prettier over the whole repo.
This server cannot be deployed
Maintenance
ActivityStale
ResponsivenessNo issues