Skip to main content
Glama
devniel

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.