Skip to content

devniel/Tic-Tac-Toe-Claude-Code

Repository files navigation

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

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:

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)

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.

About

An example of playing Tic-Tac-Toe with Claude Code using Claude Code Channels

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages