diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b17db4d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+server/data/
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..665fa81
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,13 @@
+FROM node:20-alpine
+WORKDIR /app/server
+
+ENV PORT=8080
+
+COPY server/package.json server/pnpm-lock.yaml ./
+RUN corepack pnpm@9 install --prod
+
+COPY server/*.js ./
+COPY public/ /app/public
+
+EXPOSE 8080
+CMD ["node", "server.js"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..4b146c6
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# Tic Tac Toe
+
+A tic-tac-toe game with two modes: **CPU** (play against a minimax AI) and **Online** (real-time 2-player over WebSocket).
+
+## Stack
+- Frontend: vanilla JS + CSS (no build step)
+- Backend: Node.js + Express (serves static files) + `ws` (WebSocket) + `sql.js` (SQLite, in-memory)
+
+## Project layout
+```
+public/ frontend (index.html, app.js, game.js, multiplayer.js, style.css)
+server/ backend (server.js, db.js, package.json, pnpm-lock.yaml)
+docs/ design docs
+Dockerfile production image
+docker-compose.yml
+```
+
+## Run locally
+
+The backend lives in `server/` (the frontend has no build step).
+
+```bash
+# install backend deps (pnpm)
+cd server && pnpm install
+
+# start (default port 8080)
+node server/server.js
+# or with an explicit port
+PORT=8080 node server/server.js
+```
+
+Then open .
+
+> The server reads `PORT` (default `8080`) and optional `BASE_PATH` (default empty).
+
+## Run with Docker
+
+```bash
+docker compose up --build
+```
+
+or build and run the image directly:
+
+```bash
+docker build -t tictactoe .
+docker run -p 8080:8080 tictactoe
+```
+
+Then open .
+
+## Notes
+- `server/data/` holds the SQLite file and is git-ignored.
+- Online games persist to SQLite; a host who reloads can re-enter via the saved token.
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..281fd67
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,10 @@
+services:
+ ttt:
+ build: .
+ ports:
+ - "8080:8080"
+ environment:
+ - BASE_PATH=/game
+ volumes:
+ - ./server/data:/app/server/data
+ restart: unless-stopped
diff --git a/docs/MULTIPLAYER_PLAN.md b/docs/MULTIPLAYER_PLAN.md
new file mode 100644
index 0000000..0a8e9c7
--- /dev/null
+++ b/docs/MULTIPLAYER_PLAN.md
@@ -0,0 +1,563 @@
+# 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 `