Files
ttt/docs/MULTIPLAYER_PLAN.md
T

564 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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://<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:
```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 `<button class="cell">` cells **once** and own the
cell `click` handler. The handler delegates to the active mode:
```js
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`:
```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)
```bash
# 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.
```