Files
ttt/docs/MULTIPLAYER_PLAN.md
T

28 KiB
Raw Blame History

Tic-Tac-Toe — Online Multiplayer (Player vs Player) — Implementation Plan

Adds a player-vs-player online mode to the existing tic-tac-toe app. Two human players connect over the internet, are matched through a shareable invite link, and take turns on a shared board that stays in sync in real time. All existing game rules (3×3 board, X moves first, win/draw detection, scoring, win-line, confetti) apply unchanged.

This plan is written to be followed step-by-step by a junior developer. Each step says what to build, why, and how to verify it. Follow the steps in order.


1. Goals & Non-Goals

Goals (MVP)

  • A menu screen where the player chooses: Play vs CPU (existing mode) or New Online Game (new mode).
  • Host flow: clicking New Online Game creates a game on the server and returns a shareable link containing a token. The host copies/sends that link by any channel (chat, email, etc.).
  • Joiner flow: the second player opens the link in a browser. The app reads the token from the URL and connects to the same game session the host created.
  • Once both players are connected, the game starts. Host is X (moves first), joiner is O. Players alternate turns by tapping the board.
  • Real-time sync: every move made by one player appears on the other player's board immediately (sub-second).
  • All normal rules apply: 8 winning lines, draw detection, win-line draw, confetti, per-match scoring, "Play Again" for a new round.
  • State is persisted in SQLite so invite tokens remain valid across server restarts.
  • Runnable locally with one command; deployable as a Docker container.

Non-Goals (kept simple on purpose)

  • No accounts, login, or player names. Players are anonymous "Player 1 / Player 2".
  • No reconnection/resume after a dropped connection (a dropped player is notified; the match is marked ended). Rejoining is possible for the joiner via the token link, but not a first-class feature.
  • No matchmaking/lobby, no rooms list, no chat, no spectators.
  • No fairness randomization of who is X (host is always X for simplicity).
  • No HTTPS/TLS termination in the app itself (put a reverse proxy / Caddy in front for public internet use).
  • No Postgres yet — SQLite now, Postgres later (see §12).

2. Tech Stack & Why

Layer Choice Why
Backend runtime Node.js 20 Already installed; the user proposed Node.
HTTP server Express 5 Minimal, well-known; serves the static frontend + a tiny REST API.
Real-time ws (WebSocket) Bidirectional, low-latency, the standard for real-time sync. Far simpler than WebRTC for a turn-based game.
Database SQLite via sql.js (WASM) Real SQLite with zero native compilation. The native better-sqlite3 build segfaults in this environment (ABI/glibc mismatch); sql.js is the same SQLite engine compiled to WebAssembly — no build step, no native deps, and it stores a real .db file. Migrates to Postgres later (§12).
Frontend Vanilla HTML/CSS/JS (existing) Kept as-is per requirements; no framework, no build step.
Package manager pnpm (via corepack) npm is not on PATH in this environment; corepack pnpm works and is fast.
Deploy Docker (node:20-alpine) One-container deploy; SQLite file persists on a volume.

Why not Spring Boot? A JVM/Spring stack is heavyweight for this MVP: long build times, large images, and far more boilerplate for a 2-player turn-based game. Node + Express + ws + SQLite is the leanest stack that meets every requirement.

Dependencies (server only)

  • express — HTTP + static serving + REST API
  • ws — WebSocket server
  • sql.js — SQLite (WASM)

No frontend dependencies. No build step for the frontend.


3. Project Structure (after this feature)

game1/
├── public/                    # Frontend (served by the Node server)
│   ├── index.html             # Page: menu + waiting + game screens
│   ├── style.css              # All styling (existing + menu/waiting styles)
│   ├── app.js                 # NEW: screens, board, shared rendering, menu, routing
│   ├── game.js                # CPU mode engine (refactored from the original)
│   └── multiplayer.js         # NEW: online mode engine (WebSocket client)
├── server/
│   ├── package.json           # express, ws, sql.js
│   ├── server.js              # HTTP server, REST API, WebSocket server, game loop
│   ├── db.js                  # SQLite wrapper (schema, queries, persistence)
│   └── data/                  # games.db lives here (gitignored)
├── docs/
│   ├── PLAN.md                # Original (CPU mode) plan
│   ├── MULTIPLAYER_PLAN.md    # THIS document
│   ├── mockup.svg             # Original UI mockup
│   ├── menu-mockup.svg        # NEW: menu screen design
│   ├── waiting-mockup.svg     # NEW: "waiting for opponent" + invite link
│   └── game-mockup.svg        # NEW: multiplayer game screen design
├── Dockerfile
├── docker-compose.yml
└── .gitignore

4. High-Level Flow

                          ┌─────────────────────────────┐
                          │        Node server          │
                          │  (Express + ws + SQLite)    │
                          └─────────────────────────────┘
                                   ▲                ▲
              HTTP POST /api/games │                │  WebSocket (real-time)
                                   │                │
        ┌──────────────┐           │                │        ┌──────────────┐
        │  HOST        │───────────┘                │        │  JOINER      │
        │  (Player 1,  │  creates game, gets        │        │  (Player 2,  │
        │   is X)      │  token + link, waits       │        │   is O)      │
        └──────────────┘                           │        └──────────────┘
              sends link ──────────────────────────► opens link, token in URL,
                                                     connects, joins game
  1. Host opens the app → sees the menu.
  2. Host clicks New Online Game → POST /api/games → server creates a game row (id, token, status=waiting) and returns { id, token, link }.
  3. Host's app opens a WebSocket to the server, registers as the host of that game, and shows the waiting screen with the invite link + a Copy button and "Waiting for opponent…".
  4. Joiner opens the link (…/?token=abc123). The app detects the token query param, opens a WebSocket, and sends { type:'join', token }.
  5. Server validates the token, links the two connections, sets status=playing, and broadcasts start to both. Host = X (turn), joiner = O.
  6. Players tap cells. Each tap → client sends { type:'move', cell } → server validates (right turn, empty cell, game active) → updates board → broadcasts { type:'move', cell, mark } to both clients → both render the mark.
  7. When a line is completed or the board fills, server sets status=ended, broadcasts { type:'end', winner, line } → both show the result overlay.
  8. Play Again (either player) → server resets the board (X first), status=playing, broadcasts { type:'new-round' } → both clear the board.

5. Data Model (SQLite)

One table. The board and turn are stored as JSON/scalars so a game can be restored after a server restart.

CREATE TABLE IF NOT EXISTS games (
  id         TEXT PRIMARY KEY,             -- UUID v4
  token      TEXT UNIQUE NOT NULL,         -- invite token (16 random hex chars)
  status     TEXT NOT NULL DEFAULT 'waiting',  -- 'waiting' | 'playing' | 'ended'
  board      TEXT NOT NULL DEFAULT 'null', -- JSON array of 9: null | 'X' | 'O'
  turn       TEXT NOT NULL DEFAULT 'X',    -- whose turn: 'X' | 'O'
  created_at TEXT NOT NULL,                -- ISO-8601 timestamp
  updated_at TEXT NOT NULL                 -- ISO-8601 timestamp
);
  • id: crypto.randomUUID() (built into Node 20).
  • token: 16 random hex chars from crypto.randomBytes(8).toString('hex'). Short enough to type/share, unique enough for an MVP. (For production, use 32+ chars or a signed token.)
  • board: JSON string of the 9-cell array, e.g. ["X",null,"O",...].
  • The in-memory games Map (see §7) holds the live WebSocket connections and is the source of truth during play. The DB is the durable record and the source of truth for token validation.

6. REST API

Base URL: the host serving the app (e.g. http://localhost:3000).

POST /api/games — create a game (host)

  • Request: no body (or {}).
  • Response 201:
    { "id": "uuid", "token": "abc123def456", "link": "http://<host>:3000/?token=abc123def456" }
    
  • The server inserts a games row with status='waiting', board='null', turn='X'.
  • link is built from the request origin: new URL(req.originalUrl).origin + '/?token=' + token. (The single-page app reads ?token= to enter joiner mode — see §8.)

GET /api/health — liveness probe (for Docker)

  • Response 200: { "ok": true }.

That's the entire REST surface. All gameplay happens over the WebSocket.


7. WebSocket Protocol

Endpoint: ws://<host>:3000/ws

The server keeps an in-memory map:

// gameId -> {
//   id, token, board: Array(9), turn: 'X'|'O', status: 'waiting'|'playing'|'ended',
//   players: { X: ws|null, O: ws|null }   // live socket per seat
// }

Client → Server messages

type payload meaning
host { gameId } Host registers as seat X for gameId.
join { token } Joiner requests seat O for the game with that token.
move { cell } Player places a mark in cell (0–8).
new-round {} Request a fresh round (after the game ended).
leave {} Player intentionally leaves.

Server → Client messages

type payload meaning
joined { you, board, turn } Sent to the joiner on successful join (and to host as opponent-joined).
opponent-joined { you } Sent to the host when the joiner connects; game becomes playing.
start { you, board, turn } Both clients: game is live. you is 'X' or 'O'.
move { cell, mark } Broadcast to both: a mark was placed.
end { winner, line } Broadcast to both: winner is 'X'/'O'/'draw', line is [i,j,k] or null.
new-round { board, turn } Broadcast to both: board cleared, X to move.
opponent-left {} The other player disconnected/left.
error { message } A request was invalid (bad token, wrong turn, occupied cell, etc.).

Move validation (server-side — the source of truth)

A move is accepted only if all are true:

  1. Game status === 'playing'.
  2. The sender's seat mark equals game.turn (it's their turn).
  3. board[cell] === null (cell empty).
  4. cell is an integer 0–8.

If any check fails, send { type:'error', message } to the sender and do not change state. This prevents race conditions from double-taps or stale clients.

On a valid move:

  1. board[cell] = turnMark.
  2. turn = (turn === 'X') ? 'O' : 'X'.
  3. Persist to DB (board + turn + status).
  4. Check for a winner/draw (same 8 lines as the frontend).
  5. Broadcast { type:'move', cell, mark } to both.
  6. If the game ended: set status='ended', persist, broadcast { type:'end', winner, line }.

Turn order

  • Host = X (always moves first), joiner = O. turn starts at 'X' and alternates. This matches the existing CPU game where X moves first.

New round

  • On new-round (accepted only when status === 'ended'): reset board to all null, turn='X', status='playing', persist, broadcast { type:'new-round' }. Either player may trigger it; the first request wins, a second is ignored (already reset).

Disconnection

  • On a socket close/leave: remove that socket from game.players[seat].
  • If the game was playing, notify the other player { type:'opponent-left' } and set status='ended' (the match can't continue without both players). Persist.
  • The remaining client shows "Opponent left" and disables the board.

8. Frontend Architecture

The frontend is three JS files + HTML + CSS, no framework, no build step.

index.html — screens

The page contains three top-level screens (only one visible at a time):

  1. #screen-menu — the landing screen:

    • Title "Tic-Tac-Toe"
    • Button Play vs CPU (ghost)
    • Button New Online Game (primary)
  2. #screen-waiting — shown to the host after creating a game:

    • Title "Share this link with your opponent"
    • A read-only link box showing the invite link
    • Copy Link button (uses navigator.clipboard, with a prompt() fallback)
    • Pulsing status "Waiting for opponent…"
    • Back to Menu button
  3. #screen-game — the actual game (shared by CPU and online modes):

    • Scoreboard (You / Opponent / Draws) — labels adapt per mode
    • Status bar ("Your turn" / "Opponent's turn" / result)
    • 3×3 board + win-line SVG
    • Buttons: New Game / Play Again (primary), Reset Scores (ghost), Back to Menu (ghost)
    • Result overlay (win/loss/draw) + confetti layer + toast

The game screen's DOM (board, cells, scoreboard, status, overlay) is shared between CPU and online modes. The JS modules drive it.

app.js — shared logic + routing (NEW)

Responsibilities:

  • Routing on load: read new URLSearchParams(location.search).get('token').
    • If a token is present → joiner flow: call Multiplayer.init({ asJoiner: true, token }) and show the game screen (in a "connecting" state).
    • Otherwise → show #screen-menu.
  • Screen management: showScreen('menu' | 'waiting' | 'game') toggles display on the three screens.
  • Board building: create the 9 <button class="cell"> cells once and own the cell click handler. The handler delegates to the active mode:
    function onCellTap(i) {
      if (activeMode === 'cpu')    CpuGame.handleMove(i);
      if (activeMode === 'online') Multiplayer.handleMove(i);
    }
    
  • Shared rendering (used by both modes): renderMark(i, mark), renderStatus(text, mode), drawWinLine(line), clearWinLine(), showOverlay(title, winner), hideOverlay(), burstConfetti(), renderScores(changed), clearBoard() (reset all cells to empty), resetScores().
  • Mode lifecycle:
    • startCpuMode(): show game screen, activeMode='cpu', CpuGame.init().
    • startOnlineHost({ id, token, link }): show waiting screen, activeMode='online', Multiplayer.init({ asHost: true, id, token, link }).
    • backToMenu(): destroy the active mode (CpuGame.destroy() / Multiplayer.destroy()), activeMode=null, show menu.
  • Scoreboard labels: in CPU mode "You (X) / CPU (O)"; in online mode "You (X) / Opponent (O)" for the host, "Opponent (X) / You (O)" for the joiner.

game.js — CPU engine (refactored)

The original monolith is refactored into a module exposing a global CpuGame:

  • CpuGame.init() — set state (board, turn='X', gameOver=false), load scores from localStorage, render. Does not auto-run on page load (the menu comes first). Must be idempotent: clearBoard() before building state.
  • CpuGame.handleMove(i) — the local player's X move: validate (not game over, turn==='X', cell empty), place X via App.renderMark, check winner; if not over, set turn='O', show "CPU is thinking…", schedule cpuMove after 600 ms.
  • CpuGame.cpuMove() — pick a random empty cell, place O via App.renderMark, check end; else turn='X', "Your turn".
  • CpuGame.newRound() — clear board, turn='X', gameOver=false, hide overlay.
  • CpuGame.resetScores() — zero scores, persist, render.
  • CpuGame.destroy() — clear the CPU timer, reset state.
  • All rendering goes through App.* (shared). The win-detection (checkWinner, WIN_LINES) lives here and is reused by the online mode via App (see below).

Win detection is shared. checkWinner(board) and WIN_LINES are defined once (in game.js) and exposed as App.checkWinner / App.WIN_LINES so multiplayer.js can use the exact same logic for local prediction/animation. The server has its own identical copy (it must, since it's the source of truth).

multiplayer.js — online engine (NEW)

A module exposing a global Multiplayer:

  • Multiplayer.init({ asHost, asJoiner, id, token, link }):
    • Store config. Open new WebSocket('ws(s)://' + location.host + '/ws').
    • On open:
      • Host → send { type:'host', gameId: id }.
      • Joiner → send { type:'join', token }.
    • Message handlers (see table below) update local state and call App.* to render.
    • Track myMark ('X' for host, 'O' for joiner) and myTurn (derived from game.turn === myMark).
  • Multiplayer.handleMove(i):
    • Local guard: game active, myTurn, cell empty. (The server re-validates.)
    • Optimistic local render for snappy feel, then send { type:'move', cell:i }. (The authoritative move broadcast from the server re-renders both sides idempotently — renderMark overwrites, so no conflict.)
  • Multiplayer.newRound(): send { type:'new-round' }.
  • Multiplayer.destroy(): send { type:'leave' }, close the socket, reset state.
  • Waiting screen (host only): show the link + Copy button + "Waiting for opponent…". When opponent-joined/start arrives, switch to the game screen.
  • Message handlers:
    msg action
    joined / start set myMark, board, turn; render board; show game screen; status "Your turn"/"Opponent's turn".
    opponent-joined (host) game is live; show game screen.
    move App.renderMark(cell, mark); update local board; if cell is mine, it's now opponent's turn; re-render status.
    end update score, App.drawWinLine(line) if line, show overlay (confetti if I won), status text.
    new-round App.clearBoard(); reset local board/turn; status "Your turn"/"Opponent's turn".
    opponent-left show "Opponent left" overlay/toast; disable board.
    error toast the message.
    socket close toast "Connection lost"; show back-to-menu.
  • Scoring in online mode: track wins/losses/draws for the match (not localStorage) — reset when the match ends. Display in the scoreboard. (Persisting online scores to localStorage is a possible later enhancement; keep it in-memory for the MVP.)

style.css — additions

  • Styles for #screen-menu (two big buttons, centered), #screen-waiting (link box, copy button, pulsing "waiting" text), and the "Back to Menu" ghost button.
  • Reuse the existing glassmorphism theme tokens (.btn, .chip, .cell, overlay, confetti, toast) — the game screen is unchanged visually.
  • A .link-box (read-only, selectable text, monospace-ish, with a copy affordance).
  • .waiting-pulse reuses the existing status-pulse keyframes.

9. Step-by-Step Implementation (for a junior dev)

Work top-down. After each step, run the Verify item before moving on.

Step 0 — Bootstrap the server

  • Create server/package.json:
    {
      "name": "ttt-server",
      "private": true,
      "type": "commonjs",
      "scripts": { "start": "node server.js" },
      "dependencies": {
        "express": "^5.2.1",
        "ws": "^8.21.3",
        "sql.js": "^1.14.2"
      }
    }
    
  • Install: cd server && corepack pnpm@9 install (or pnpm install if pnpm is on PATH).
  • Verify: node -e "require('express');require('ws');require('sql.js');console.log('ok')" prints ok (add process.exit(0) if the process lingers).

Step 1 — server/db.js (SQLite wrapper)

  • Use sql.js. On startup: load data/games.db if it exists, else create a new DB and run the CREATE TABLE games (...) schema (§5).
  • Provide helpers:
    • createGame({ id, token }) → insert row (status='waiting', board='null', turn='X', timestamps).
    • getGameByToken(token) → row or null.
    • getGameById(id) → row or null.
    • updateGame(id, { status, board, turn }) → UPDATE.
    • persist() → db.export() → write bytes to data/games.db.
  • Persist after every mutation (cheap for a tiny DB).
  • Verify: a tiny script that creates a game, reads it back by token, updates it, re-loads the file, and confirms the row survived.

Step 2 — server/server.js (HTTP + WS + game loop)

  • Express app:
    • app.use(express.static(path.join(__dirname, '../public'))).
    • app.post('/api/games', ...) → create game via db.createGame, return { id, token, link } (201). Build link from req origin.
    • app.get('/api/health', ...) → { ok: true }.
    • SPA fallback: app.get('*', ...) → serve public/index.html (so /?token=… loads the app).
  • WebSocket server (ws):
    • Attach to the HTTP server on path /ws.
    • Maintain the games Map (§7). On host/join messages, resolve the game (from memory or DB), attach the socket to the seat, and when both seats are filled set status='playing' and broadcast start.
    • Implement move validation (§7), new-round, disconnection handling.
  • Verify (with two browser tabs or wscat):
    • Tab A: create game (via the UI or curl -X POST localhost:3000/api/games), see the link.
    • Tab B: open the link, both tabs show "start", play a few moves — both boards update in real time. Test a win (win-line draws on both), a draw, and "Play Again".

Step 3 — Frontend restructure

  • Move index.html, style.css, game.js into public/ (already done via git mv).
  • Rewrite index.html to contain the three screens (§8) with only #screen-menu visible initially. Keep the existing board/scoreboard/overlay markup inside #screen-game. Add <script> tags for app.js, game.js, multiplayer.js (order: game.js, multiplayer.js, then app.js so App is defined last and can reference the others — or use defer and call App.init() on DOMContentLoaded).
  • Verify: opening the app shows the menu with two buttons; no console errors.

Step 4 — public/app.js (shared + routing)

  • Implement screen management, board building, shared rendering (move the rendering functions out of game.js into app.js), cell-click delegation, mode lifecycle, and token-based routing (§8).
  • Verify: "Play vs CPU" starts the existing CPU game exactly as before (win, draw, confetti, scores persist). "Back to Menu" returns to the menu and re-entering CPU mode works cleanly (no duplicate cells).

Step 5 — public/game.js (CPU refactor)

  • Refactor the original into the CpuGame module (§8): remove the auto init() call, expose init/handleMove/cpuMove/newRound/resetScores/destroy, and route all rendering through App.*.
  • Verify: CPU mode is unchanged in behavior (see Step 4 verify).

Step 6 — public/multiplayer.js (online engine)

  • Implement Multiplayer (§8): WebSocket connect, host/join handshake, message handlers, optimistic move, waiting screen, scoring, disconnection handling.
  • Verify (two tabs / two browsers):
    • Host: New Online Game → link appears → copy.
    • Joiner: open link → both show the board → host is X, joiner is O.
    • Alternate moves; both boards stay in sync in real time.
    • Win → win-line + confetti on the winner's side, "You Win"/"Opponent wins" on each.
    • Draw → "It's a Draw" on both.
    • Play Again → both boards clear, X (host) to move.
    • Close one tab → the other shows "Opponent left".

Step 7 — Styling

  • Add menu/waiting/back-button styles (§8). Match the existing glassmorphism theme.
  • Verify: at 360 px width, no overflow; buttons are big touch targets; the link box is tappable to select/copy.

Step 8 — Docker

  • Dockerfile (multi-stage or single-stage on node:20-alpine):
    • WORKDIR /app/server
    • Copy server/package.json + server/package-lock (pnpm-lock.yaml), install deps.
    • Copy server/*.js.
    • Copy ../public to /app/public.
    • EXPOSE 3000, CMD ["node","server.js"].
    • For sql.js, the WASM file is in node_modules/sql.js/dist — it's loaded from node_modules, so copying node_modules (or installing in the image) is required.
  • docker-compose.yml: one service, build: ., ports: ["3000:3000"], a volume for ./server/data:/app/server/data (SQLite persistence), restart: unless-stopped.
  • Verify: docker compose up --build → open http://localhost:3000 → full multiplayer flow works. Restart the container → an existing invite token still resolves (DB persisted on the volume).

Step 9 — Final test pass

  • Full end-to-end on a mid-range phone + desktop: create, share, join, play, win, draw, play again, disconnect, reload.
  • Check the 360 px viewport, fast taps (no double placement — the server rejects out-of-turn/occupied moves), and connection loss.

10. Security & Robustness Notes (MVP-aware)

  • Server is the source of truth. The client's optimistic render is cosmetic; the server validates every move (§7). A tampered client cannot cheat.
  • Tokens are 16 hex chars (64 bits) — fine for an MVP. For public internet use, lengthen them and/or add a TTL (expire waiting games after e.g. 10 min) and an expires_at column.
  • No auth by design (anonymous 2-player game). Don't expose personal data.
  • Rate limiting / abuse: out of scope for the MVP; add in front of a reverse proxy for public use.
  • HTTPS: terminate TLS at a reverse proxy (Caddy/nginx) for internet play; the app itself serves plain HTTP for LAN/dev.
  • CORS: same-origin (the server serves the frontend), so no CORS config needed.

11. Testing Checklist

  • Menu shows two options; no console errors.
  • CPU mode unchanged (win/draw/confetti/scores/back-to-menu).
  • New Online Game returns a valid link; Copy works.
  • Joiner opens link → connects to the host's game (host = X, joiner = O).
  • Moves sync in real time on both boards.
  • Out-of-turn / occupied / double-tap moves are rejected by the server.
  • Win → win-line + confetti on winner, correct status on both.
  • Draw → "It's a Draw" on both.
  • Play Again resets both boards, X (host) to move.
  • Disconnect → the other player is notified; match ends.
  • Server restart → an existing token still resolves (SQLite persisted).
  • 360 px viewport: no overflow, big touch targets, no zoom on tap.
  • Docker container starts and serves the full flow.

12. Future Work (post-MVP)

  • Postgres: swap the db.js wrapper's storage from sql.js to pg/postgres. The schema (§5) and query helpers stay the same shape; only the driver changes. The in-memory games Map and WebSocket layer are unaffected.
  • Reconnection/resume (client re-attaches to a playing game by token/id).
  • Fair X/O randomization per round; player names; match history/leaderboard in the DB.
  • Token TTL + cleanup job; matchmaking beyond a single invite link.
  • HTTPS/TLS, rate limiting, and a reverse proxy for public internet deployment.

13. How to Run (local, no Docker)

# 1. Install server deps (once)
cd server && corepack pnpm@9 install && cd ..

# 2. Start the server (serves the frontend + API + WebSocket on :3000)
node server/server.js
#    → http://localhost:3000

# 3. Play:
#    - Open http://localhost:3000 in two browser tabs (or two browsers / a phone on
#      the same network using http://<machine-ip>:3000).
#    - Tab A: New Online Game → copy the link.
#    - Tab B: open the link → play together.