# 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. ```sql 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**: ```json { "id": "uuid", "token": "abc123def456", "link": "http://: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://:3000/ws` The server keeps an in-memory map: ```js // 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 `