Multiplayer mode: server, room tokens, docker setup, and client UI

This commit is contained in:
robi committed 2026-09-27 07:45:11 +03:00
1 parent 176d7cc300
commit 434c81d0b6
19 files changed
+2563 -382

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
node_modules/
server/data/
+13
View File
@@ -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"]
+53
View File
@@ -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 <http://localhost:8080/>.
> 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 <http://localhost:8080/>.
## 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.
+10
View File
@@ -0,0 +1,10 @@
services:
ttt:
build: .
ports:
- "8080:8080"
environment:
- BASE_PATH=/game
volumes:
- ./server/data:/app/server/data
restart: unless-stopped
+563
View File
@@ -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://<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.
```
+92
View File
@@ -0,0 +1,92 @@
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0d0f24"/>
<stop offset="1" stop-color="#1c1140"/>
</linearGradient>
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="55"/>
</filter>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="420" height="780" fill="url(#bgGrad)"/>
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
<!-- title -->
<text x="210" y="66" text-anchor="middle" font-size="30" font-weight="700" fill="url(#titleGrad)">Tic-Tac-Toe</text>
<text x="210" y="88" text-anchor="middle" font-size="12" fill="#ffffff" opacity="0.45">Online · You are X</text>
<!-- scoreboard -->
<g>
<rect x="18" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="78" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">YOU (X)</text>
<text x="78" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#22d3ee">1</text>
<rect x="150" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="210" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">FRIEND (O)</text>
<text x="210" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#f472b6">0</text>
<rect x="282" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
<text x="342" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">DRAWS</text>
<text x="342" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#ffffff">0</text>
</g>
<!-- status -->
<text x="210" y="200" text-anchor="middle" font-size="14" fill="#22d3ee" opacity="0.9">Your turn</text>
<!-- board panel -->
<rect x="40" y="220" width="340" height="340" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.12"/>
<!-- cells -->
<g fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.10">
<rect x="54" y="234" width="96" height="96" rx="16"/>
<rect x="162" y="234" width="96" height="96" rx="16"/>
<rect x="270" y="234" width="96" height="96" rx="16"/>
<rect x="54" y="342" width="96" height="96" rx="16"/>
<rect x="162" y="342" width="96" height="96" rx="16"/>
<rect x="270" y="342" width="96" height="96" rx="16"/>
<rect x="54" y="450" width="96" height="96" rx="16"/>
<rect x="162" y="450" width="96" height="96" rx="16"/>
<rect x="270" y="450" width="96" height="96" rx="16"/>
</g>
<!-- marks -->
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="80" y1="260" x2="124" y2="304"/>
<line x1="124" y1="260" x2="80" y2="304"/>
</g>
<circle cx="210" cy="282" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<circle cx="102" cy="390" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="188" y1="368" x2="232" y2="412"/>
<line x1="232" y1="368" x2="188" y2="412"/>
</g>
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
<line x1="296" y1="368" x2="340" y2="412"/>
<line x1="340" y1="368" x2="296" y2="412"/>
</g>
<circle cx="210" cy="498" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
<!-- buttons -->
<rect x="60" y="600" width="300" height="54" rx="27" fill="url(#btnGrad)"/>
<text x="210" y="633" text-anchor="middle" font-size="16" font-weight="700" fill="#0d1025">Play Again</text>
<rect x="60" y="668" width="300" height="48" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
<text x="210" y="697" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.7">Back to Menu</text>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

+63
View File
@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0d0f24"/>
<stop offset="1" stop-color="#1c1140"/>
</linearGradient>
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="55"/>
</filter>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- background -->
<rect width="420" height="780" fill="url(#bgGrad)"/>
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
<!-- title -->
<text x="210" y="150" text-anchor="middle" font-size="34" font-weight="800" fill="url(#titleGrad)">Tic-Tac-Toe</text>
<text x="210" y="184" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.5">Choose how to play</text>
<!-- decorative mini board hint -->
<g opacity="0.5">
<rect x="150" y="230" width="120" height="120" rx="18" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.12"/>
<g stroke="#ffffff" stroke-opacity="0.18" stroke-width="2">
<line x1="190" y1="240" x2="190" y2="340"/>
<line x1="230" y1="240" x2="230" y2="340"/>
<line x1="160" y1="270" x2="340" y2="270" transform="translate(-10,0)"/>
<line x1="160" y1="310" x2="340" y2="310" transform="translate(-10,0)"/>
</g>
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="5" stroke-linecap="round">
<line x1="168" y1="252" x2="186" y2="270"/>
<line x1="186" y1="252" x2="168" y2="270"/>
</g>
<circle cx="272" cy="322" r="12" fill="none" stroke="#f472b6" stroke-width="5" filter="url(#glow)"/>
</g>
<!-- buttons -->
<!-- primary: New Online Game -->
<rect x="60" y="470" width="300" height="58" rx="29" fill="url(#btnGrad)"/>
<text x="210" y="505" text-anchor="middle" font-size="17" font-weight="700" fill="#0d1025">New Online Game</text>
<!-- ghost: Play vs CPU -->
<rect x="60" y="548" width="300" height="54" rx="27" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
<text x="210" y="580" text-anchor="middle" font-size="16" font-weight="600" fill="#ffffff" opacity="0.75">Play vs CPU</text>
<!-- hint -->
<text x="210" y="648" text-anchor="middle" font-size="12" fill="#ffffff" opacity="0.4">Online: share a link and play a friend</text>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#0d0f24"/>
<stop offset="1" stop-color="#1c1140"/>
</linearGradient>
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#22d3ee"/>
<stop offset="1" stop-color="#f472b6"/>
</linearGradient>
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="55"/>
</filter>
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
<feGaussianBlur stdDeviation="5" result="blur"/>
<feMerge>
<feMergeNode in="blur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<rect width="420" height="780" fill="url(#bgGrad)"/>
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
<!-- header -->
<text x="210" y="120" text-anchor="middle" font-size="28" font-weight="800" fill="url(#titleGrad)">Invite a friend</text>
<text x="210" y="152" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.5">Share this link to start playing</text>
<!-- link card -->
<rect x="50" y="200" width="320" height="70" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.16"/>
<text x="210" y="232" text-anchor="middle" font-size="14" fill="#22d3ee" font-family="monospace">https://game1.app/?token=a3f9c21b7e4d08f5</text>
<text x="210" y="254" text-anchor="middle" font-size="11" fill="#ffffff" opacity="0.4">Your invite link</text>
<!-- copy button -->
<rect x="110" y="300" width="200" height="50" rx="25" fill="url(#titleGrad)"/>
<text x="210" y="331" text-anchor="middle" font-size="15" font-weight="700" fill="#0d1025">Copy link</text>
<!-- waiting indicator -->
<g>
<circle cx="185" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.9"/>
<circle cx="210" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.55"/>
<circle cx="235" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.3"/>
</g>
<text x="210" y="472" text-anchor="middle" font-size="15" fill="#ffffff" opacity="0.65">Waiting for opponent to join...</text>
<!-- back button -->
<rect x="130" y="640" width="160" height="48" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
<text x="210" y="670" text-anchor="middle" font-size="14" font-weight="600" fill="#ffffff" opacity="0.7">Back to menu</text>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

-315
View File
@@ -1,315 +0,0 @@
'use strict';
/* ---------- 1. DOM refs ---------- */
const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
const statusTextEl = document.getElementById('status-text');
const overlayEl = document.getElementById('overlay');
const overlayTitleEl = document.getElementById('overlay-title');
const winLineEl = document.getElementById('win-line-el');
const confettiLayer = document.getElementById('confetti-layer');
const toastEl = document.getElementById('toast');
const scoreEls = {
you: document.getElementById('score-you'),
cpu: document.getElementById('score-cpu'),
draws: document.getElementById('score-draws'),
};
const cells = [];
/* ---------- state ---------- */
const state = {
board: Array(9).fill(null),
turn: 'X',
gameOver: false,
scores: { you: 0, cpu: 0, draws: 0 },
};
let cpuTimer = 0;
let toastTimer = 0;
const WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
const SCORES_KEY = 'ttt-scores';
const CPU_DELAY = 600;
const X_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-x" aria-hidden="true">' +
'<line x1="24" y1="24" x2="76" y2="76"></line>' +
'<line x1="76" y1="24" x2="24" y2="76"></line>' +
'</svg>';
const O_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-o" aria-hidden="true">' +
'<circle cx="50" cy="50" r="30"></circle>' +
'</svg>';
/* ---------- 2. Win detection ---------- */
function checkWinner(board) {
for (const line of WIN_LINES) {
const [a, b, c] = line;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { winner: board[a], line };
}
}
if (board.every((v) => v !== null)) return { winner: 'draw' };
return null;
}
/* ---------- 3. Player move ---------- */
function onCellTap(i) {
if (state.gameOver || state.turn !== 'X' || state.board[i]) return;
placeMark(i, 'X');
const result = checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'O';
renderStatus('CPU is thinking…', 'thinking');
cpuTimer = setTimeout(cpuMove, CPU_DELAY);
}
/* ---------- 4. CPU move ---------- */
function cpuMove() {
if (state.gameOver) return;
const empty = [];
state.board.forEach((v, i) => {
if (v === null) empty.push(i);
});
if (!empty.length) return;
const i = empty[Math.floor(Math.random() * empty.length)];
placeMark(i, 'O');
const result = checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'X';
renderStatus('Your turn', 'normal');
}
function placeMark(i, mark) {
state.board[i] = mark;
renderMark(i, mark);
}
/* ---------- 5. Render ---------- */
function renderMark(i, mark) {
const cell = cells[i];
cell.classList.remove('empty');
cell.classList.add(mark === 'X' ? 'has-x' : 'has-o');
cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O');
cell.innerHTML = mark === 'X' ? X_SVG : O_SVG;
}
function renderStatus(text, mode) {
mode = mode || 'normal';
statusEl.classList.toggle('thinking', mode === 'thinking');
if (statusTextEl.textContent === text) return;
statusEl.classList.add('fading');
setTimeout(() => {
statusTextEl.textContent = text;
statusEl.classList.remove('fading');
}, 150);
}
function renderScores(changed) {
for (const k of ['you', 'cpu', 'draws']) {
scoreEls[k].textContent = state.scores[k];
if (k === changed) {
scoreEls[k].classList.remove('pop');
void scoreEls[k].offsetWidth;
scoreEls[k].classList.add('pop');
}
}
}
function cellCenter(i, boardRect) {
const r = cells[i].getBoundingClientRect();
return {
x: r.left + r.width / 2 - boardRect.left,
y: r.top + r.height / 2 - boardRect.top,
};
}
function drawWinLine(line) {
const boardRect = boardEl.getBoundingClientRect();
const p1 = cellCenter(line[0], boardRect);
const p2 = cellCenter(line[2], boardRect);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const ext = 16;
const ux = dx / len;
const uy = dy / len;
const x1 = p1.x - ux * ext;
const y1 = p1.y - uy * ext;
const x2 = p2.x + ux * ext;
const y2 = p2.y + uy * ext;
winLineEl.setAttribute('x1', x1);
winLineEl.setAttribute('y1', y1);
winLineEl.setAttribute('x2', x2);
winLineEl.setAttribute('y2', y2);
const total = Math.hypot(x2 - x1, y2 - y1);
winLineEl.style.transition = 'none';
winLineEl.style.strokeDasharray = total;
winLineEl.style.strokeDashoffset = total;
winLineEl.style.opacity = '1';
void winLineEl.getBoundingClientRect();
winLineEl.style.transition = 'stroke-dashoffset 400ms ease';
winLineEl.style.strokeDashoffset = '0';
}
function clearWinLine() {
winLineEl.style.transition = 'none';
winLineEl.style.opacity = '0';
winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray;
}
/* ---------- 6. Round end ---------- */
function endRound(result) {
state.gameOver = true;
let title;
let key;
if (result.winner === 'X') {
key = 'you';
title = 'You Win! 🎉';
renderStatus('You win! 🎉', 'normal');
} else if (result.winner === 'O') {
key = 'cpu';
title = 'CPU Wins';
renderStatus('CPU wins', 'normal');
boardEl.classList.add('shake');
} else {
key = 'draws';
title = "It's a Draw";
renderStatus("It's a draw", 'normal');
boardEl.classList.add('pulse');
}
state.scores[key]++;
persistScores();
renderScores(key);
if (result.line) drawWinLine(result.line);
setTimeout(() => {
overlayTitleEl.textContent = title;
overlayEl.classList.add('show');
if (result.winner === 'X') burstConfetti();
}, result.winner === 'draw' ? 400 : 600);
}
/* ---------- 7. Confetti ---------- */
const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff'];
function burstConfetti() {
const pieces = [];
for (let i = 0; i < 40; i++) {
const el = document.createElement('div');
el.className = 'confetti';
const size = 6 + Math.random() * 8;
el.style.width = size + 'px';
el.style.height = size * 1.4 + 'px';
el.style.left = Math.random() * 100 + 'vw';
el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px');
el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg');
el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's';
el.style.animationDelay = Math.random() * 0.25 + 's';
confettiLayer.appendChild(el);
pieces.push(el);
}
setTimeout(() => pieces.forEach((p) => p.remove()), 2200);
}
/* ---------- 8. Reset ---------- */
function newRound() {
clearTimeout(cpuTimer);
state.board = Array(9).fill(null);
state.turn = 'X';
state.gameOver = false;
cells.forEach((cell) => {
cell.innerHTML = '';
cell.classList.add('empty');
cell.classList.remove('has-x', 'has-o');
cell.setAttribute('aria-label', 'Empty cell');
});
clearWinLine();
boardEl.classList.remove('shake', 'pulse');
overlayEl.classList.remove('show');
confettiLayer.innerHTML = '';
renderStatus('Your turn', 'normal');
}
function resetScores() {
state.scores = { you: 0, cpu: 0, draws: 0 };
persistScores();
renderScores();
showToast('Scores reset');
}
function persistScores() {
try {
localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores));
} catch (e) {
/* storage unavailable */
}
}
function loadScores() {
try {
const raw = localStorage.getItem(SCORES_KEY);
if (raw) {
const s = JSON.parse(raw);
state.scores = {
you: Number(s.you) || 0,
cpu: Number(s.cpu) || 0,
draws: Number(s.draws) || 0,
};
}
} catch (e) {
/* corrupted storage — keep defaults */
}
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600);
}
/* ---------- 9. Init ---------- */
function buildBoard() {
for (let i = 0; i < 9; i++) {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cell empty';
cell.dataset.index = String(i);
cell.setAttribute('aria-label', 'Empty cell');
cell.addEventListener('click', () => onCellTap(i));
boardEl.appendChild(cell);
cells.push(cell);
}
}
function init() {
buildBoard();
loadScores();
renderScores();
document.getElementById('btn-new').addEventListener('click', newRound);
document.getElementById('btn-reset').addEventListener('click', resetScores);
document.getElementById('btn-again').addEventListener('click', newRound);
renderStatus('Your turn', 'normal');
}
init();
-67
View File
@@ -1,67 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f24">
<title>Tic-Tac-Toe</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="bg-blob blob-cyan" aria-hidden="true"></div>
<div class="bg-blob blob-magenta" aria-hidden="true"></div>
<main class="app">
<h1 class="title">Tic-Tac-Toe</h1>
<div class="scoreboard">
<div class="chip" id="chip-you">
<span class="chip-label">You (X)</span>
<span class="chip-value chip-x" id="score-you">0</span>
</div>
<div class="chip" id="chip-cpu">
<span class="chip-label">CPU (O)</span>
<span class="chip-value chip-o" id="score-cpu">0</span>
</div>
<div class="chip" id="chip-draws">
<span class="chip-label">Draws</span>
<span class="chip-value" id="score-draws">0</span>
</div>
</div>
<div class="status" id="status">
<span class="status-text" id="status-text">Your turn</span>
</div>
<div class="board-wrap">
<div class="board" id="board" aria-label="Tic-tac-toe board"></div>
<svg class="win-line" id="win-line" aria-hidden="true">
<defs>
<linearGradient id="win-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee"></stop>
<stop offset="1" stop-color="#f472b6"></stop>
</linearGradient>
</defs>
<line id="win-line-el" stroke="url(#win-grad)" stroke-width="6" stroke-linecap="round" opacity="0"></line>
</svg>
</div>
<div class="buttons">
<button class="btn primary" id="btn-new" type="button">New Game</button>
<button class="btn ghost" id="btn-reset" type="button">Reset Scores</button>
</div>
</main>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-title" id="overlay-title">You Win! 🎉</div>
<button class="btn primary" id="btn-again" type="button">Play Again</button>
</div>
</div>
<div id="confetti-layer" aria-hidden="true"></div>
<div class="toast" id="toast" aria-live="polite"></div>
<script src="game.js"></script>
</body>
</html>
+305
View File
@@ -0,0 +1,305 @@
'use strict';
const BASE = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, '');
const App = (() => {
const boardEl = document.getElementById('board');
const statusEl = document.getElementById('status');
const statusTextEl = document.getElementById('status-text');
const overlayEl = document.getElementById('overlay');
const overlayTitleEl = document.getElementById('overlay-title');
const winLineEl = document.getElementById('win-line-el');
const confettiLayer = document.getElementById('confetti-layer');
const toastEl = document.getElementById('toast');
const scoreEls = {
you: document.getElementById('score-you'),
opponent: document.getElementById('score-opponent'),
draws: document.getElementById('score-draws'),
};
const labelYou = document.getElementById('label-you');
const labelOpponent = document.getElementById('label-opponent');
const cells = [];
let toastTimer = 0;
let activeMode = null;
const WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
const X_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-x" aria-hidden="true">' +
'<line x1="24" y1="24" x2="76" y2="76"></line>' +
'<line x1="76" y1="24" x2="24" y2="76"></line>' +
'</svg>';
const O_SVG =
'<svg viewBox="0 0 100 100" class="mark mark-o" aria-hidden="true">' +
'<circle cx="50" cy="50" r="30"></circle>' +
'</svg>';
function checkWinner(board) {
for (const line of WIN_LINES) {
const [a, b, c] = line;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { winner: board[a], line };
}
}
if (board.every((v) => v !== null)) return { winner: 'draw' };
return null;
}
function showScreen(name) {
document.getElementById('screen-menu').style.display = name === 'menu' ? '' : 'none';
document.getElementById('screen-waiting').style.display = name === 'waiting' ? '' : 'none';
document.getElementById('screen-game').style.display = name === 'game' ? '' : 'none';
}
function buildBoard() {
boardEl.innerHTML = '';
cells.length = 0;
for (let i = 0; i < 9; i++) {
const cell = document.createElement('button');
cell.type = 'button';
cell.className = 'cell empty';
cell.dataset.index = String(i);
cell.setAttribute('aria-label', 'Empty cell');
cell.addEventListener('click', () => onCellTap(i));
boardEl.appendChild(cell);
cells.push(cell);
}
}
function onCellTap(i) {
if (activeMode === 'cpu') CpuGame.handleMove(i);
if (activeMode === 'online') Multiplayer.handleMove(i);
}
function renderMark(i, mark) {
const cell = cells[i];
cell.classList.remove('empty');
cell.classList.add(mark === 'X' ? 'has-x' : 'has-o');
cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O');
cell.innerHTML = mark === 'X' ? X_SVG : O_SVG;
}
function renderStatus(text, mode) {
mode = mode || 'normal';
statusEl.classList.toggle('thinking', mode === 'thinking');
if (statusTextEl.textContent === text) return;
statusEl.classList.add('fading');
setTimeout(() => {
statusTextEl.textContent = text;
statusEl.classList.remove('fading');
}, 150);
}
function renderScores(scores) {
scoreEls.you.textContent = scores.you || 0;
scoreEls.opponent.textContent = scores.opponent || 0;
scoreEls.draws.textContent = scores.draws || 0;
}
function cellCenter(i, boardRect) {
const r = cells[i].getBoundingClientRect();
return {
x: r.left + r.width / 2 - boardRect.left,
y: r.top + r.height / 2 - boardRect.top,
};
}
function drawWinLine(line) {
const boardRect = boardEl.getBoundingClientRect();
const p1 = cellCenter(line[0], boardRect);
const p2 = cellCenter(line[2], boardRect);
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.hypot(dx, dy) || 1;
const ext = 16;
const ux = dx / len;
const uy = dy / len;
const x1 = p1.x - ux * ext;
const y1 = p1.y - uy * ext;
const x2 = p2.x + ux * ext;
const y2 = p2.y + uy * ext;
winLineEl.setAttribute('x1', x1);
winLineEl.setAttribute('y1', y1);
winLineEl.setAttribute('x2', x2);
winLineEl.setAttribute('y2', y2);
const total = Math.hypot(x2 - x1, y2 - y1);
winLineEl.style.transition = 'none';
winLineEl.style.strokeDasharray = total;
winLineEl.style.strokeDashoffset = total;
winLineEl.style.opacity = '1';
void winLineEl.getBoundingClientRect();
winLineEl.style.transition = 'stroke-dashoffset 400ms ease';
winLineEl.style.strokeDashoffset = '0';
}
function clearWinLine() {
winLineEl.style.transition = 'none';
winLineEl.style.opacity = '0';
winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray;
}
function showOverlay(title) {
overlayTitleEl.textContent = title;
overlayEl.classList.add('show');
}
function hideOverlay() {
overlayEl.classList.remove('show');
}
const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff'];
function burstConfetti() {
const pieces = [];
for (let i = 0; i < 40; i++) {
const el = document.createElement('div');
el.className = 'confetti';
const size = 6 + Math.random() * 8;
el.style.width = size + 'px';
el.style.height = size * 1.4 + 'px';
el.style.left = Math.random() * 100 + 'vw';
el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px');
el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg');
el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's';
el.style.animationDelay = Math.random() * 0.25 + 's';
confettiLayer.appendChild(el);
pieces.push(el);
}
setTimeout(() => pieces.forEach((p) => p.remove()), 2200);
}
function clearBoard() {
cells.forEach((cell) => {
cell.innerHTML = '';
cell.classList.add('empty');
cell.classList.remove('has-x', 'has-o');
cell.setAttribute('aria-label', 'Empty cell');
});
clearWinLine();
boardEl.classList.remove('shake', 'pulse');
hideOverlay();
confettiLayer.innerHTML = '';
}
function showToast(msg) {
toastEl.textContent = msg;
toastEl.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600);
}
function setScoreboardLabels(youLabel, oppLabel) {
labelYou.textContent = youLabel;
labelOpponent.textContent = oppLabel;
}
function startCpuMode() {
activeMode = 'cpu';
showScreen('game');
setScoreboardLabels('You (X)', 'CPU (O)');
CpuGame.init();
}
function startOnlineHost({ id, token, link }) {
activeMode = 'online';
showScreen('waiting');
document.getElementById('link-text').textContent = link;
Multiplayer.init({ asHost: true, id, token, link });
}
function startOnlineJoiner(token) {
activeMode = 'online';
showScreen('game');
setScoreboardLabels('Opponent (X)', 'You (O)');
renderStatus('Connecting…', 'thinking');
Multiplayer.init({ asJoiner: true, token });
}
function backToMenu() {
if (activeMode === 'cpu') CpuGame.destroy();
if (activeMode === 'online') Multiplayer.destroy();
activeMode = null;
clearBoard();
hideOverlay();
showScreen('menu');
}
function init() {
buildBoard();
document.getElementById('btn-cpu').addEventListener('click', startCpuMode);
document.getElementById('btn-online').addEventListener('click', async () => {
try {
const res = await fetch(BASE + '/api/games', { method: 'POST' });
if (!res.ok) throw new Error('Server returned ' + res.status);
const data = await res.json();
startOnlineHost(data);
} catch (e) {
showToast('Error: ' + e.message);
console.error('Create game failed:', e);
}
});
document.getElementById('btn-copy').addEventListener('click', async () => {
const link = document.getElementById('link-text').textContent;
try {
await navigator.clipboard.writeText(link);
showToast('Link copied!');
} catch (e) {
prompt('Copy this link:', link);
}
});
document.getElementById('btn-back-menu-waiting').addEventListener('click', backToMenu);
document.getElementById('btn-new-game').addEventListener('click', () => {
if (activeMode === 'cpu') CpuGame.newRound();
if (activeMode === 'online') Multiplayer.newRound();
});
document.getElementById('btn-again-overlay').addEventListener('click', () => {
if (activeMode === 'cpu') CpuGame.newRound();
if (activeMode === 'online') Multiplayer.newRound();
});
document.getElementById('btn-reset').addEventListener('click', () => {
if (activeMode === 'cpu') CpuGame.resetScores();
});
const token = new URLSearchParams(location.search).get('token');
if (token) {
startOnlineJoiner(token);
} else {
showScreen('menu');
}
}
return {
init,
showScreen,
renderMark,
renderStatus,
renderScores,
drawWinLine,
clearWinLine,
showOverlay,
hideOverlay,
burstConfetti,
clearBoard,
showToast,
setScoreboardLabels,
checkWinner,
WIN_LINES,
get activeMode() { return activeMode; },
};
})();
App.init();
+131
View File
@@ -0,0 +1,131 @@
'use strict';
const CpuGame = (() => {
const SCORES_KEY = 'ttt-scores';
const CPU_DELAY = 600;
const state = {
board: Array(9).fill(null),
turn: 'X',
gameOver: false,
scores: { you: 0, opponent: 0, draws: 0 },
};
let cpuTimer = 0;
function persistScores() {
try {
localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores));
} catch (e) { /* storage unavailable */ }
}
function loadScores() {
try {
const raw = localStorage.getItem(SCORES_KEY);
if (raw) {
const s = JSON.parse(raw);
state.scores = {
you: Number(s.you) || 0,
opponent: Number(s.cpu || s.opponent) || 0,
draws: Number(s.draws) || 0,
};
}
} catch (e) { /* corrupted storage */ }
}
function handleMove(i) {
if (state.gameOver || state.turn !== 'X' || state.board[i]) return;
state.board[i] = 'X';
App.renderMark(i, 'X');
const result = App.checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'O';
App.renderStatus('CPU is thinking…', 'thinking');
cpuTimer = setTimeout(cpuMove, CPU_DELAY);
}
function cpuMove() {
if (state.gameOver) return;
const empty = [];
state.board.forEach((v, i) => { if (v === null) empty.push(i); });
if (!empty.length) return;
const i = empty[Math.floor(Math.random() * empty.length)];
state.board[i] = 'O';
App.renderMark(i, 'O');
const result = App.checkWinner(state.board);
if (result) {
endRound(result);
return;
}
state.turn = 'X';
App.renderStatus('Your turn', 'normal');
}
function endRound(result) {
state.gameOver = true;
let title;
let key;
if (result.winner === 'X') {
key = 'you';
title = 'You Win!';
App.renderStatus('You win!', 'normal');
} else if (result.winner === 'O') {
key = 'opponent';
title = 'CPU Wins';
App.renderStatus('CPU wins', 'normal');
document.getElementById('board').classList.add('shake');
} else {
key = 'draws';
title = "It's a Draw";
App.renderStatus("It's a draw", 'normal');
document.getElementById('board').classList.add('pulse');
}
state.scores[key]++;
persistScores();
App.renderScores(state.scores);
if (result.line) App.drawWinLine(result.line);
setTimeout(() => {
App.showOverlay(title);
if (result.winner === 'X') App.burstConfetti();
}, result.winner === 'draw' ? 400 : 600);
}
function newRound() {
clearTimeout(cpuTimer);
state.board = Array(9).fill(null);
state.turn = 'X';
state.gameOver = false;
App.clearBoard();
App.renderStatus('Your turn', 'normal');
}
function resetScores() {
state.scores = { you: 0, opponent: 0, draws: 0 };
persistScores();
App.renderScores(state.scores);
App.showToast('Scores reset');
}
function destroy() {
clearTimeout(cpuTimer);
}
function init() {
clearTimeout(cpuTimer);
App.clearBoard();
state.board = Array(9).fill(null);
state.turn = 'X';
state.gameOver = false;
loadScores();
App.renderScores(state.scores);
App.renderStatus('Your turn', 'normal');
}
return { init, handleMove, newRound, resetScores, destroy };
})();
+92
View File
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0d0f24">
<title>Tic-Tac-Toe</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="bg-blob blob-cyan" aria-hidden="true"></div>
<div class="bg-blob blob-magenta" aria-hidden="true"></div>
<main class="app">
<section id="screen-menu" class="screen">
<h1 class="title">Tic-Tac-Toe</h1>
<div class="menu-buttons">
<button class="btn ghost" id="btn-cpu" type="button">Play vs CPU</button>
<button class="btn primary" id="btn-online" type="button">New Online Game</button>
</div>
</section>
<section id="screen-waiting" class="screen" style="display:none">
<h1 class="title">Share this link</h1>
<p class="waiting-subtitle">Send this link to your opponent</p>
<div class="link-box" id="link-box">
<span id="link-text"></span>
</div>
<button class="btn primary" id="btn-copy" type="button">Copy Link</button>
<div class="status waiting-pulse" id="waiting-status">
<span class="status-text">Waiting for opponent…</span>
</div>
<button class="btn ghost" id="btn-back-menu-waiting" type="button">Back to Menu</button>
</section>
<section id="screen-game" class="screen" style="display:none">
<div class="scoreboard">
<div class="chip" id="chip-you">
<span class="chip-label" id="label-you">You (X)</span>
<span class="chip-value chip-x" id="score-you">0</span>
</div>
<div class="chip" id="chip-opponent">
<span class="chip-label" id="label-opponent">CPU (O)</span>
<span class="chip-value chip-o" id="score-opponent">0</span>
</div>
<div class="chip" id="chip-draws">
<span class="chip-label">Draws</span>
<span class="chip-value" id="score-draws">0</span>
</div>
</div>
<div class="status" id="status">
<span class="status-text" id="status-text">Your turn</span>
</div>
<div class="board-wrap">
<div class="board" id="board" aria-label="Tic-tac-toe board"></div>
<svg class="win-line" id="win-line" aria-hidden="true">
<defs>
<linearGradient id="win-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#22d3ee"></stop>
<stop offset="1" stop-color="#f472b6"></stop>
</linearGradient>
</defs>
<line id="win-line-el" stroke="url(#win-grad)" stroke-width="6" stroke-linecap="round" opacity="0"></line>
</svg>
</div>
<div class="buttons">
<button class="btn" id="btn-reset" type="button">Reset Scores</button>
<button class="btn primary" id="btn-new-game" type="button">New Game</button>
</div>
</section>
</main>
<div class="overlay" id="overlay" aria-hidden="true">
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-title" id="overlay-title">You Win!</div>
<button class="btn primary" id="btn-again-overlay" type="button">Play Again</button>
</div>
</div>
<div id="confetti-layer" aria-hidden="true"></div>
<div class="toast" id="toast" aria-live="polite"></div>
<script src="game.js"></script>
<script src="multiplayer.js"></script>
<script src="app.js"></script>
</body>
</html>
+163
View File
@@ -0,0 +1,163 @@
'use strict';
const Multiplayer = (() => {
let ws = null;
let asHost = false;
let gameId = null;
let myMark = null;
let myTurn = false;
let board = Array(9).fill(null);
let gameOver = false;
let scores = { you: 0, opponent: 0, draws: 0 };
function connect(url) {
return new Promise((resolve, reject) => {
ws = new WebSocket(url);
ws.onopen = () => {
if (asHost) {
ws.send(JSON.stringify({ type: 'host', id: gameId }));
} else {
ws.send(JSON.stringify({ type: 'join', token: gameId }));
}
resolve();
};
ws.onerror = (e) => {
App.showToast('Connection failed');
reject(e);
};
ws.onclose = () => {
if (!gameOver) {
App.renderStatus('Connection lost', 'normal');
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
handleMessage(msg);
};
});
}
function handleMessage(msg) {
switch (msg.type) {
case 'joined':
myMark = msg.mark;
myTurn = msg.mark === 'X';
App.setScoreboardLabels(
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
);
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
break;
case 'opponent-joined':
App.showScreen('game');
App.setScoreboardLabels(
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
);
App.renderStatus('Your turn', 'normal');
myTurn = true;
break;
case 'start':
board = Array(9).fill(null);
gameOver = false;
myMark = msg.mark;
myTurn = msg.first === myMark;
App.setScoreboardLabels(
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
);
App.clearBoard();
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
break;
case 'move': {
board[msg.index] = msg.mark;
App.renderMark(msg.index, msg.mark);
if (!gameOver && msg.mark !== myMark) {
myTurn = !myTurn;
App.renderStatus(myTurn ? 'Your turn' : 'Opponent is thinking…', myTurn ? 'normal' : 'thinking');
}
break;
}
case 'end':
gameOver = true;
if (msg.winner === 'draw') {
scores.draws++;
App.renderStatus("It's a draw", 'normal');
document.getElementById('board').classList.add('pulse');
} else {
const iWon = msg.winner === myMark;
scores[iWon ? 'you' : 'opponent']++;
App.renderStatus(iWon ? 'You win!' : 'Opponent wins', 'normal');
if (!iWon) document.getElementById('board').classList.add('shake');
}
App.renderScores(scores);
if (msg.line) App.drawWinLine(msg.line);
setTimeout(() => {
const title = msg.winner === 'draw' ? "It's a Draw" : (msg.winner === myMark ? 'You Win!' : 'Opponent Wins');
App.showOverlay(title);
if (msg.winner === myMark) App.burstConfetti();
}, 600);
break;
case 'new-round':
board = Array(9).fill(null);
gameOver = false;
myTurn = msg.first === myMark;
App.hideOverlay();
App.clearBoard();
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
break;
case 'opponent-left':
gameOver = true;
App.renderStatus('Opponent left', 'error');
break;
case 'error':
App.showToast(msg.message || 'Error');
break;
}
}
function handleMove(i) {
if (gameOver || !myTurn || board[i] || myMark === null) return;
board[i] = myMark;
App.renderMark(i, myMark);
ws.send(JSON.stringify({ type: 'move', index: i }));
myTurn = false;
App.renderStatus('Waiting for opponent…', 'thinking');
}
function newRound() {
ws.send(JSON.stringify({ type: 'new-round' }));
}
function destroy() {
if (ws) {
ws.close();
ws = null;
}
myMark = null;
myTurn = false;
board = Array(9).fill(null);
gameOver = false;
scores = { you: 0, opponent: 0, draws: 0 };
}
function init(opts) {
asHost = opts.asHost;
gameId = opts.asHost ? opts.id : opts.token;
const base = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, '');
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + '/ws';
connect(wsUrl).catch((err) => {
console.error('Connection failed:', err);
App.renderStatus('Connection failed', 'error');
});
}
return { init, handleMove, newRound, destroy };
})();
+44
View File
@@ -429,3 +429,47 @@ body {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------- screens ---------- */
.screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
width: 100%;
flex: 1;
}
.menu-buttons {
display: flex;
flex-direction: column;
gap: 14px;
width: 100%;
max-width: 280px;
}
.waiting-subtitle {
font-size: 14px;
color: var(--muted);
}
.link-box {
width: 100%;
max-width: 320px;
padding: 14px 18px;
background: var(--glass);
border: 1px solid var(--glass-border);
border-radius: 14px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
font-size: 13px;
font-family: monospace;
word-break: break-all;
color: rgba(255, 255, 255, 0.85);
text-align: center;
}
.waiting-pulse {
animation: status-pulse 1200ms ease-in-out infinite;
}
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const fs = require('fs');
const path = require('path');
const initSqlJs = require('sql.js');
const DATA_DIR = path.join(__dirname, 'data');
const DB_PATH = path.join(DATA_DIR, 'games.db');
let db = null;
async function init() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
const SQL = await initSqlJs();
if (fs.existsSync(DB_PATH)) {
const buf = fs.readFileSync(DB_PATH);
db = new SQL.Database(buf);
} else {
db = new SQL.Database();
db.run(`CREATE TABLE IF NOT EXISTS games (
id TEXT PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
status TEXT NOT NULL DEFAULT 'waiting',
board TEXT NOT NULL DEFAULT 'null',
turn TEXT NOT NULL DEFAULT 'X',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`);
persist();
}
}
function createGame({ id, token }) {
const now = new Date().toISOString();
db.run(
`INSERT INTO games (id, token, status, board, turn, created_at, updated_at)
VALUES (?, ?, 'waiting', 'null', 'X', ?, ?)`,
[id, token, now, now]
);
persist();
}
function getGameByToken(token) {
const stmt = db.prepare(`SELECT * FROM games WHERE token = ?`);
stmt.bind([token]);
if (stmt.step()) {
const row = stmt.getAsObject();
stmt.free();
return row;
}
stmt.free();
return null;
}
function getGameById(id) {
const stmt = db.prepare(`SELECT * FROM games WHERE id = ?`);
stmt.bind([id]);
if (stmt.step()) {
const row = stmt.getAsObject();
stmt.free();
return row;
}
stmt.free();
return null;
}
function updateGame(id, { status, board, turn }) {
const now = new Date().toISOString();
db.run(
`UPDATE games SET status = ?, board = ?, turn = ?, updated_at = ? WHERE id = ?`,
[status, board, turn, now, id]
);
persist();
}
function persist() {
const data = db.export();
fs.writeFileSync(DB_PATH, Buffer.from(data));
}
module.exports = { init, createGame, getGameByToken, getGameById, updateGame, persist };
+11
View File
@@ -0,0 +1,11 @@
{
"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"
}
}
+597
View File
@@ -0,0 +1,597 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
express:
specifier: ^5.2.1
version: 5.2.1
sql.js:
specifier: ^1.14.2
version: 1.14.2
ws:
specifier: ^8.21.3
version: 8.21.3
packages:
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
body-parser@2.3.0:
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
engines: {node: '>=18'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
call-bind-apply-helpers@1.0.2:
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
engines: {node: '>= 0.4'}
call-bound@1.0.4:
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
engines: {node: '>= 0.4'}
content-disposition@1.1.0:
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
engines: {node: '>=18'}
content-type@1.0.5:
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
engines: {node: '>= 0.6'}
content-type@2.1.0:
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
engines: {node: '>=18'}
cookie-signature@1.2.2:
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
engines: {node: '>=6.6.0'}
cookie@0.7.2:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
encodeurl@2.0.0:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
es-errors@1.3.0:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
express@5.2.1:
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
engines: {node: '>= 18'}
finalhandler@2.1.1:
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
engines: {node: '>= 18.0.0'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
get-proto@1.0.1:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
hasown@2.0.4:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
iconv-lite@0.7.3:
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
engines: {node: '>=0.10.0'}
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
media-typer@1.1.1:
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
engines: {node: '>= 0.8'}
merge-descriptors@2.0.0:
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
engines: {node: '>=18'}
mime-db@1.54.0:
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
engines: {node: '>= 0.6'}
mime-types@3.0.2:
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
engines: {node: '>=18'}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
negotiator@1.1.0:
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
engines: {node: '>=18'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-to-regexp@8.4.2:
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
proxy-addr@2.0.8:
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
engines: {node: '>= 0.10'}
qs@6.16.0:
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'}
range-parser@1.3.0:
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
raw-body@3.0.2:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
send@1.2.1:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
serve-static@2.2.1:
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
engines: {node: '>= 18'}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
side-channel-map@1.0.1:
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
engines: {node: '>= 0.4'}
side-channel-weakmap@1.0.2:
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
engines: {node: '>= 0.4'}
side-channel@1.1.1:
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
engines: {node: '>= 0.4'}
sql.js@1.14.2:
resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==}
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
toidentifier@1.0.1:
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
engines: {node: '>=0.6'}
type-is@2.1.0:
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
engines: {node: '>= 18'}
unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
ws@8.21.3:
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
snapshots:
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
negotiator: 1.1.0
body-parser@2.3.0:
dependencies:
bytes: 3.1.2
content-type: 2.1.0
debug: 4.4.3
http-errors: 2.0.1
iconv-lite: 0.7.3
on-finished: 2.4.1
qs: 6.16.0
raw-body: 3.0.2
type-is: 2.1.0
transitivePeerDependencies:
- supports-color
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
dependencies:
es-errors: 1.3.0
function-bind: 1.1.2
call-bound@1.0.4:
dependencies:
call-bind-apply-helpers: 1.0.2
get-intrinsic: 1.3.0
content-disposition@1.1.0: {}
content-type@1.0.5: {}
content-type@2.1.0: {}
cookie-signature@1.2.2: {}
cookie@0.7.2: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
depd@2.0.0: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
es-errors: 1.3.0
gopd: 1.2.0
ee-first@1.1.1: {}
encodeurl@2.0.0: {}
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
escape-html@1.0.3: {}
etag@1.8.1: {}
express@5.2.1:
dependencies:
accepts: 2.0.0
body-parser: 2.3.0
content-disposition: 1.1.0
content-type: 1.0.5
cookie: 0.7.2
cookie-signature: 1.2.2
debug: 4.4.3
depd: 2.0.0
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
finalhandler: 2.1.1
fresh: 2.0.0
http-errors: 2.0.1
merge-descriptors: 2.0.0
mime-types: 3.0.2
on-finished: 2.4.1
once: 1.4.0
parseurl: 1.3.3
proxy-addr: 2.0.8
qs: 6.16.0
range-parser: 1.3.0
router: 2.2.0
send: 1.2.1
serve-static: 2.2.1
statuses: 2.0.2
type-is: 2.1.0
vary: 1.1.2
transitivePeerDependencies:
- supports-color
finalhandler@2.1.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
parseurl: 1.3.3
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
forwarded@0.2.0: {}
fresh@2.0.0: {}
function-bind@1.1.2: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
es-define-property: 1.0.1
es-errors: 1.3.0
es-object-atoms: 1.1.2
function-bind: 1.1.2
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.4
math-intrinsics: 1.1.0
get-proto@1.0.1:
dependencies:
dunder-proto: 1.0.1
es-object-atoms: 1.1.2
gopd@1.2.0: {}
has-symbols@1.1.0: {}
hasown@2.0.4:
dependencies:
function-bind: 1.1.2
http-errors@2.0.1:
dependencies:
depd: 2.0.0
inherits: 2.0.4
setprototypeof: 1.2.0
statuses: 2.0.2
toidentifier: 1.0.1
iconv-lite@0.7.3:
dependencies:
safer-buffer: 2.1.2
inherits@2.0.4: {}
ipaddr.js@1.9.1: {}
is-promise@4.0.0: {}
math-intrinsics@1.1.0: {}
media-typer@1.1.1: {}
merge-descriptors@2.0.0: {}
mime-db@1.54.0: {}
mime-types@3.0.2:
dependencies:
mime-db: 1.54.0
ms@2.1.3: {}
negotiator@1.1.0:
dependencies:
content-type: 2.1.0
object-inspect@1.13.4: {}
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
once@1.4.0:
dependencies:
wrappy: 1.0.2
parseurl@1.3.3: {}
path-to-regexp@8.4.2: {}
proxy-addr@2.0.8:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
qs@6.16.0:
dependencies:
es-define-property: 1.0.1
side-channel: 1.1.1
range-parser@1.3.0: {}
raw-body@3.0.2:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.3
unpipe: 1.0.0
router@2.2.0:
dependencies:
debug: 4.4.3
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
path-to-regexp: 8.4.2
transitivePeerDependencies:
- supports-color
safer-buffer@2.1.2: {}
send@1.2.1:
dependencies:
debug: 4.4.3
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
fresh: 2.0.0
http-errors: 2.0.1
mime-types: 3.0.2
ms: 2.1.3
on-finished: 2.4.1
range-parser: 1.3.0
statuses: 2.0.2
transitivePeerDependencies:
- supports-color
serve-static@2.2.1:
dependencies:
encodeurl: 2.0.0
escape-html: 1.0.3
parseurl: 1.3.3
send: 1.2.1
transitivePeerDependencies:
- supports-color
setprototypeof@1.2.0: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-map@1.0.1:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-weakmap@1.0.2:
dependencies:
call-bound: 1.0.4
es-errors: 1.3.0
get-intrinsic: 1.3.0
object-inspect: 1.13.4
side-channel-map: 1.0.1
side-channel@1.1.1:
dependencies:
es-errors: 1.3.0
object-inspect: 1.13.4
side-channel-list: 1.0.1
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
sql.js@1.14.2: {}
statuses@2.0.2: {}
toidentifier@1.0.1: {}
type-is@2.1.0:
dependencies:
content-type: 2.1.0
media-typer: 1.1.1
mime-types: 3.0.2
unpipe@1.0.0: {}
vary@1.1.2: {}
wrappy@1.0.2: {}
ws@8.21.3: {}
+288
View File
@@ -0,0 +1,288 @@
'use strict';
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const express = require('express');
const WebSocket = require('ws');
const db = require('./db');
const PORT = process.env.PORT || 8080;
const BASE_PATH = process.env.BASE_PATH || '';
const WIN_LINES = [
[0, 1, 2], [3, 4, 5], [6, 7, 8],
[0, 3, 6], [1, 4, 7], [2, 5, 8],
[0, 4, 8], [2, 4, 6],
];
function checkWinner(board) {
for (const line of WIN_LINES) {
const [a, b, c] = line;
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
return { winner: board[a], line };
}
}
if (board.every((v) => v !== null)) return { winner: 'draw' };
return null;
}
const app = express();
const server = http.createServer(app);
const wss = new WebSocket.Server({ server, path: '/ws' });
const games = new Map();
app.use(express.static(path.join(__dirname, '../public')));
app.post('/api/games', (req, res) => {
const id = crypto.randomUUID();
const token = crypto.randomBytes(8).toString('hex');
db.createGame({ id, token });
const link = `${req.get('origin') || req.protocol + '://' + req.get('host')}${BASE_PATH}/?token=${token}`;
res.status(201).json({ id, token, link });
});
app.get('/api/health', (req, res) => {
res.json({ ok: true });
});
app.use((req, res, next) => {
if (req.method === 'GET' && !req.path.startsWith('/api/')) {
res.sendFile(path.join(__dirname, '../public', 'index.html'));
} else {
next();
}
});
function send(ws, obj) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(obj));
}
}
function broadcast(game, obj) {
for (const seat of ['X', 'O']) {
const p = game.players[seat];
if (p) send(p, obj);
}
}
wss.on('connection', (ws) => {
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch (e) {
return;
}
switch (msg.type) {
case 'host':
handleHost(ws, msg);
break;
case 'join':
handleJoin(ws, msg);
break;
case 'move':
handleMove(ws, msg);
break;
case 'new-round':
handleNewRound(ws, msg);
break;
case 'leave':
handleLeave(ws);
break;
}
});
ws.on('close', () => {
handleDisconnect(ws);
});
});
function handleHost(ws, msg) {
const row = db.getGameById(msg.id);
if (!row) {
send(ws, { type: 'error', message: 'Game not found' });
return;
}
let game = games.get(row.id);
if (!game) {
const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board);
game = {
id: row.id,
token: row.token,
board,
turn: row.turn,
status: row.status,
players: { X: null, O: null },
};
games.set(game.id, game);
}
game.players.X = ws;
ws._gameId = game.id;
ws._seat = 'X';
if (game.status === 'waiting') {
send(ws, { type: 'joined', mark: 'X', board: game.board, turn: game.turn });
} else if (game.status === 'playing' && game.players.O) {
send(ws, { type: 'start', mark: 'X', first: 'X', board: game.board, turn: game.turn });
}
}
function handleJoin(ws, msg) {
const row = db.getGameByToken(msg.token);
if (!row) {
send(ws, { type: 'error', message: 'Invalid or expired link' });
return;
}
let game = games.get(row.id);
if (!game) {
const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board);
game = {
id: row.id,
token: row.token,
board,
turn: row.turn,
status: row.status,
players: { X: null, O: ws },
};
games.set(game.id, game);
}
if (game.status === 'ended') {
send(ws, { type: 'error', message: 'Game already ended' });
return;
}
if (game.players.O) {
send(ws, { type: 'error', message: 'Game already full' });
return;
}
game.players.O = ws;
ws._gameId = game.id;
ws._seat = 'O';
if (game.players.X) {
game.status = 'playing';
db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn });
send(game.players.X, { type: 'opponent-joined', you: 'X' });
send(ws, { type: 'start', mark: 'O', first: 'X', board: game.board, turn: game.turn });
} else {
send(ws, { type: 'joined', mark: 'O', board: game.board, turn: game.turn });
}
}
function handleMove(ws, msg) {
const game = games.get(ws._gameId);
if (!game) {
send(ws, { type: 'error', message: 'Game not found' });
return;
}
if (game.status !== 'playing') {
send(ws, { type: 'error', message: 'Game is not in progress' });
return;
}
const seat = ws._seat;
if (game.turn !== seat) {
send(ws, { type: 'error', message: 'Not your turn' });
return;
}
const cell = msg.index;
if (!Number.isInteger(cell) || cell < 0 || cell > 8) {
send(ws, { type: 'error', message: 'Invalid cell' });
return;
}
if (game.board[cell] !== null) {
send(ws, { type: 'error', message: 'Cell already occupied' });
return;
}
game.board[cell] = seat;
game.turn = seat === 'X' ? 'O' : 'X';
db.updateGame(game.id, { status: game.status, board: JSON.stringify(game.board), turn: game.turn });
const result = checkWinner(game.board);
broadcast(game, { type: 'move', index: cell, mark: seat });
if (result) {
game.status = 'ended';
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
broadcast(game, { type: 'end', winner: result.winner, line: result.line || null });
}
}
function handleNewRound(ws, msg) {
const game = games.get(ws._gameId);
if (!game) {
send(ws, { type: 'error', message: 'Game not found' });
return;
}
if (game.status !== 'ended') {
send(ws, { type: 'error', message: 'Game has not ended' });
return;
}
game.board = Array(9).fill(null);
game.turn = 'X';
game.status = 'playing';
db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn });
broadcast(game, { type: 'new-round', first: 'X', board: game.board, turn: game.turn });
}
function handleLeave(ws) {
handleDisconnect(ws);
}
function handleDisconnect(ws) {
const gameId = ws._gameId;
if (!gameId) return;
const game = games.get(gameId);
if (!game) return;
const seat = ws._seat;
if (game.players[seat] === ws) {
game.players[seat] = null;
}
if (game.status === 'playing') {
game.status = 'ended';
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
const otherSeat = seat === 'X' ? 'O' : 'X';
const other = game.players[otherSeat];
if (other) {
send(other, { type: 'opponent-left' });
}
} else if (game.status === 'waiting' && seat === 'X') {
game.status = 'ended';
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
}
if (!game.players.X && !game.players.O) {
games.delete(game.id);
}
}
db.init().then(() => {
server.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
}).catch((err) => {
console.error('Failed to initialize database:', err);
process.exit(1);
});