28 KiB
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 APIws— WebSocket serversql.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
- Host opens the app → sees the menu.
- Host clicks New Online Game →
POST /api/games→ server creates a game row (id, token, status=waiting) and returns{ id, token, link }. - 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…".
- Joiner opens the link (
…/?token=abc123). The app detects thetokenquery param, opens a WebSocket, and sends{ type:'join', token }. - Server validates the token, links the two connections, sets status=
playing, and broadcasts start to both. Host = X (turn), joiner = O. - 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. - When a line is completed or the board fills, server sets status=
ended, broadcasts{ type:'end', winner, line }→ both show the result overlay. - 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
gamesMap (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
gamesrow withstatus='waiting',board='null',turn='X'. linkis 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:
- Game
status === 'playing'. - The sender's seat mark equals
game.turn(it's their turn). board[cell] === null(cell empty).cellis 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:
board[cell] = turnMark.turn = (turn === 'X') ? 'O' : 'X'.- Persist to DB (board + turn + status).
- Check for a winner/draw (same 8 lines as the frontend).
- Broadcast
{ type:'move', cell, mark }to both. - If the game ended: set
status='ended', persist, broadcast{ type:'end', winner, line }.
Turn order
- Host = X (always moves first), joiner = O.
turnstarts at'X'and alternates. This matches the existing CPU game where X moves first.
New round
- On
new-round(accepted only whenstatus === 'ended'): resetboardto allnull,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 fromgame.players[seat]. - If the game was
playing, notify the other player{ type:'opponent-left' }and setstatus='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):
-
#screen-menu— the landing screen:- Title "Tic-Tac-Toe"
- Button Play vs CPU (ghost)
- Button New Online Game (primary)
-
#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 aprompt()fallback) - Pulsing status "Waiting for opponent…"
- Back to Menu button
-
#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
tokenis present → joiner flow: callMultiplayer.init({ asJoiner: true, token })and show the game screen (in a "connecting" state). - Otherwise → show
#screen-menu.
- If a
- Screen management:
showScreen('menu' | 'waiting' | 'game')togglesdisplayon the three screens. - Board building: create the 9
<button class="cell">cells once and own the cellclickhandler. 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 fromlocalStorage, 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 viaApp.renderMark, check winner; if not over, setturn='O', show "CPU is thinking…", schedulecpuMoveafter 600 ms.CpuGame.cpuMove()— pick a random empty cell, place O viaApp.renderMark, check end; elseturn='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 viaApp(see below).
Win detection is shared.
checkWinner(board)andWIN_LINESare defined once (ingame.js) and exposed asApp.checkWinner/App.WIN_LINESsomultiplayer.jscan 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 }.
- Host → send
- Message handlers (see table below) update local state and call
App.*to render. - Track
myMark('X'for host,'O'for joiner) andmyTurn(derived fromgame.turn === myMark).
- Store config. Open
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 authoritativemovebroadcast from the server re-renders both sides idempotently —renderMarkoverwrites, so no conflict.)
- Local guard: game active,
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/startarrives, switch to the game screen. - Message handlers:
msg action joined/startset myMark, board, turn; render board; show game screen; status "Your turn"/"Opponent's turn".opponent-joined(host) game is live; show game screen. moveApp.renderMark(cell, mark); update local board; ifcellis mine, it's now opponent's turn; re-render status.endupdate score, App.drawWinLine(line)if line, show overlay (confetti if I won), status text.new-roundApp.clearBoard(); reset local board/turn; status "Your turn"/"Opponent's turn".opponent-leftshow "Opponent left" overlay/toast; disable board. errortoast the message. socket closetoast "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 tolocalStorageis 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-pulsereuses the existingstatus-pulsekeyframes.
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(orpnpm installif pnpm is on PATH). - Verify:
node -e "require('express');require('ws');require('sql.js');console.log('ok')"printsok(addprocess.exit(0)if the process lingers).
Step 1 — server/db.js (SQLite wrapper)
- Use
sql.js. On startup: loaddata/games.dbif it exists, else create a new DB and run theCREATE TABLE games (...)schema (§5). - Provide helpers:
createGame({ id, token })→ insert row (status='waiting',board='null',turn='X', timestamps).getGameByToken(token)→ row ornull.getGameById(id)→ row ornull.updateGame(id, { status, board, turn })→UPDATE.persist()→db.export()→ write bytes todata/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 viadb.createGame, return{ id, token, link }(201). Buildlinkfromreqorigin.app.get('/api/health', ...)→{ ok: true }.- SPA fallback:
app.get('*', ...)→ servepublic/index.html(so/?token=…loads the app).
- WebSocket server (
ws):- Attach to the HTTP server on path
/ws. - Maintain the
gamesMap (§7). Onhost/joinmessages, resolve the game (from memory or DB), attach the socket to the seat, and when both seats are filled setstatus='playing'and broadcaststart. - Implement move validation (§7), new-round, disconnection handling.
- Attach to the HTTP server on path
- 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".
- Tab A: create game (via the UI or
Step 3 — Frontend restructure
- Move
index.html,style.css,game.jsintopublic/(already done viagit mv). - Rewrite
index.htmlto contain the three screens (§8) with only#screen-menuvisible initially. Keep the existing board/scoreboard/overlay markup inside#screen-game. Add<script>tags forapp.js,game.js,multiplayer.js(order:game.js,multiplayer.js, thenapp.jssoAppis defined last and can reference the others — or usedeferand callApp.init()onDOMContentLoaded). - 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.jsintoapp.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
CpuGamemodule (§8): remove the autoinit()call, exposeinit/handleMove/cpuMove/newRound/resetScores/destroy, and route all rendering throughApp.*. - 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 onnode:20-alpine):WORKDIR /app/server- Copy
server/package.json+server/package-lock(pnpm-lock.yaml), install deps. - Copy
server/*.js. - Copy
../publicto/app/public. EXPOSE 3000,CMD ["node","server.js"].- For
sql.js, the WASM file is innode_modules/sql.js/dist— it's loaded fromnode_modules, so copyingnode_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→ openhttp://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
waitinggames after e.g. 10 min) and anexpires_atcolumn. - 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.jswrapper's storage fromsql.jstopg/postgres. The schema (§5) and query helpers stay the same shape; only the driver changes. The in-memorygamesMap and WebSocket layer are unaffected. - Reconnection/resume (client re-attaches to a
playinggame 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.