564 lines
28 KiB
Markdown
564 lines
28 KiB
Markdown
# 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.
|
||
```
|