From 9a5ad55f3095678b8cdf9e27286cd13b2ea71f8d Mon Sep 17 00:00:00 2001 From: "claude (blind_chess)" Date: Tue, 28 Apr 2026 10:53:26 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20initial=20scaffold=20=E2=80=94=20spec,?= =?UTF-8?q?=20decisions,=20gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 28 + CLAUDE.md | 36 ++ DECISIONS.md | 56 ++ IDEA.md | 48 ++ .../specs/2026-04-28-blind-chess-design.md | 604 ++++++++++++++++++ 5 files changed, 772 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DECISIONS.md create mode 100644 IDEA.md create mode 100644 docs/superpowers/specs/2026-04-28-blind-chess-design.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa4bb9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +.superpowers/ +.backup/ + +# Node +node_modules/ +.pnpm-store/ +dist/ +build/ +.svelte-kit/ +.vite/ + +# Logs / coverage +*.log +coverage/ +.vitest/ + +# Editor / OS +.DS_Store +.idea/ +.vscode/ +*.swp + +# Secrets +.env +.env.local + +# Handoff workspace artifacts (kept local) +.claude/handoffs/*.draft.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..aeda425 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,36 @@ +# blind_chess + +> Web-based two-player chess where each player sees only their own pieces; the server acts as moderator. + +## Start Here + +**Read the latest handoff first:** `.claude/handoffs/` (most recent file). + +Then check `DECISIONS.md` for settled choices, and `docs/superpowers/specs/2026-04-28-blind-chess-design.md` for the full design spec. + +## Project Identity + +A digitization of a battleship-style chess variant. Two players, separated; a moderator who sees both boards and announces a fixed vocabulary of events ("Black has moved and captured", "Moving that piece will not help you", "White is in check"). The physical version requires three people; this version replaces the moderator with a server. Ships with both **vanilla** mode (full reveal — normal chess) and **blind** mode (the variation) on day one. Mode is a per-player view filter on a shared engine, not a different game. + +The system's most distinctive property: highlighting in blind mode reveals **zero opponent information**. It's computed purely from `(piece type, position, own-piece set)` — a function whose signature literally cannot read opponent state. The moderator vocabulary is the only legitimate channel for opponent events. + +## Current State + +- **Phase:** spec approved, ready to implement (planning next) +- **Repo:** not yet (Gitea creation pending — see deferred steps in handoff) +- **Stack:** Node 22 + TypeScript, Fastify + `ws`, Svelte + Vite, `chess.js`. pnpm workspace with `packages/{server,client,shared}`. +- **Deploy target:** new LXC on node-241 behind Caddy CT 600 → `chess.sethpc.xyz`. Systemd-managed Node service on port 3000. In-memory game state only (no DB). + +## Key files + +- `IDEA.md` — original project brief (Seth's words) +- `DECISIONS.md` — locked architectural and gameplay decisions +- `docs/superpowers/specs/2026-04-28-blind-chess-design.md` — full design spec (everything: data model, protocol, FSM, testing) + +## Conventions + +- Inherits global homelab conventions from `~/bin/CLAUDE.md` (gitea CLI, conventional commits, `.claude/handoffs/` for session state). +- pnpm workspace; do not use npm/yarn lockfiles. +- All inter-package types live in `packages/shared/`. Never duplicate protocol types in client or server. +- Server is the single authority on game state. Client `commit` messages are requests, not facts. +- The view filter (`buildView`) is the only egress for board state. Don't bypass it. diff --git a/DECISIONS.md b/DECISIONS.md new file mode 100644 index 0000000..8357c60 --- /dev/null +++ b/DECISIONS.md @@ -0,0 +1,56 @@ +# DECISIONS.md — blind_chess Decision Log + +Project-specific decisions. For global/cross-cutting decisions, see `~/bin/DECISIONS.md`. + +Format: `YYYY-MM-DD: ` + +## Architecture + +- 2026-04-28: Node 22 + TypeScript stack — single-language top-to-bottom; `chess.js` is the de facto rules engine and lives natively here. +- 2026-04-28: pnpm workspace with three packages — `packages/server` (Fastify + ws), `packages/client` (Svelte + Vite), `packages/shared` (TS types). Shared types are the load-bearing decision: the WS protocol drift surface is high-risk and shared types catch it at compile time. +- 2026-04-28: Fastify > Express — better TypeScript ergonomics, faster, cleaner plugin model for `ws` integration. +- 2026-04-28: Svelte > React — smaller bundle, reactive stores fit the constantly-changing board state model. React is overkill for a 2-route app. +- 2026-04-28: `chess.js` for rules + custom `geometricMoves` helper — chess.js doesn't expose pseudo-legal moves; ~80 LoC pure function covers all six piece types. Lives in `packages/shared` so server and client use the same code. +- 2026-04-28: In-memory only; `Map` is the entire database — simplest possible. SQLite later if crash recovery becomes painful. Rejected: SQLite for MVP (premature given hobby-project scope). +- 2026-04-28: Single-port Node service — Fastify serves both static client and `/ws` upgrade on port 3000. No reverse proxy logic in our service; Caddy CT 600 handles TLS and routing. +- 2026-04-28: Deploy target: new LXC on node-241 — clean isolation, matches existing patterns. Behind Caddy CT 600 at `chess.sethpc.xyz`. +- 2026-04-28: No auth beyond the hashed game link — friction-minimal; appropriate for casual play. No Authentik gate. Rejected: gating with Authentik (overkill). +- 2026-04-28: 8-character `gameId` (32 bits, `^[a-z0-9]{8}$`), 24-character `PlayerToken` (144 bits) — game IDs short enough for hand-shareable links, tokens long enough to prevent guessing. +- 2026-04-28: WebSocket transport for in-game; REST POST `/api/games` for creation — keeps create flow simple (refresh-friendly, cacheable), keeps in-game traffic on a single channel. + +## Implementation + +- 2026-04-28: Both modes (vanilla + blind) shipped day one — single engine, mode = per-player view filter. Vanilla mode is "blind mode with full reveal." +- 2026-04-28: Moderator hierarchy refined to four tiers: (1) `no_such_piece`, (2) `no_legal_moves` = pseudo-legal ∅, (3) `wont_help` = pseudo-legal ≠∅ but legal ∅ (pin OR unresolved check), (4) silent = legal moves exist. Each tier is information-strictly-monotonic (more info revealed at later tiers). +- 2026-04-28: Touch-move FSM — tap arms (reversible, client-side only), drag-start or destination-click commits ("touches"). Server tracks `armed: { color, from }`. `no_legal_moves` and `wont_help` checks fire only on first commit with a piece; once committed, all subsequent failed attempts are `illegal_move` with the touch staying. +- 2026-04-28: Highlighting (blind+ON) is purely geometric — function of `(piece type, position, own-piece set)`, no opponent input. Rays extend through unseen opponent pieces. Stop at own pieces. Off-board excluded. Zero opponent info leak. (Vanilla+ON shows engine-truth: legal-empty as green dot, legal-capture as red ring.) +- 2026-04-28: Game creation: creator picks side at create time (default random); single-use link (first joiner takes the open slot, then locked); no spectators in MVP; link dies with the game. +- 2026-04-28: Reconnect via opaque `PlayerToken` in browser `localStorage`, 5-minute grace window — generous for phone hiccups, short enough that abandoned games end. Grace expiry → `endReason: 'abandoned'`, opponent wins. Both-sides simultaneous expiry → game ends with `winner: undefined`. +- 2026-04-28: Pawn promotion via modal (Q/R/B/N), client must include `promotion` field in the move; moderator announces the promotion (it's tactically significant — public info). +- 2026-04-28: All draws auto-detected (stalemate, insufficient material, threefold, 50-move) — casual-play friendly; no "claim" UI. +- 2026-04-28: `Announcement` is an enum (`ModeratorText`), not a free-form string. Display strings live client-side. Tests assert against enum values. +- 2026-04-28: `update` is the single, idempotent server-to-client message that includes a filtered `view` and any new `Announcement` entries. Replaying the latest `update` produces correct render. +- 2026-04-28: Moderator-vocabulary "errors" (no_such_piece, no_legal_moves, wont_help, illegal_move) come through as `Announcement` entries on `update`, NOT as `error` messages. Errors reserved for protocol failures. +- 2026-04-28: Janitor prunes finished games after 30 min idle; active games never expire (until restart). +- 2026-04-28: Rate limiting via per-token bucket on `commit`: 10/s, burst 20 — well above human pace, well below abuse. +- 2026-04-28: Mobile-first responsive design — IDEA.md's share-a-link flow strongly implies phone use. +- 2026-04-28: Logging via Pino (Fastify default) → journald. `/api/health` for Uptime Kuma probe. No Prometheus/OpenTelemetry in MVP. +- 2026-04-28: Resign + draw-offer/accept-decline flow — standard chess UX. Resignation ends without grace; disconnect applies grace. +- 2026-04-28: Game-over screen reveals full board for both sides — post-game review is part of the experience. + +## Deferred / Rejected + + + +- 2026-04-28: **Tactical-advice interpretation of "won't help you"** — rejected. The phrase is a check-resolution announcement, not engine evaluation. Subjective "this move is bad" is anti-fun and out of scope. +- 2026-04-28: **Spectator mode** — deferred. Single-use links and no spectators in MVP. Revisit if there's demand. +- 2026-04-28: **Time controls (clocks)** — deferred. Untimed correspondence-style for MVP. Optional 5+0 / 10+0 / 15+10 in a follow-up if Seth wants. +- 2026-04-28: **SQLite persistence** — deferred. In-memory only for MVP. Add when crash recovery becomes painful (1-day implementation: serialize Map on `ExecStop`, deserialize on `ExecStart`). +- 2026-04-28: **End-to-end browser tests (Playwright)** — out of scope for MVP. Protocol-level integration tests cover the same drift surface for ~10× less maintenance. Manual phone+desktop testing suffices. +- 2026-04-28: **Vanilla-only or blind-only MVP** — rejected in favor of both-from-day-one. The shared engine + view-filter architecture means vanilla is essentially free. +- 2026-04-28: **Authentik gate on `chess.sethpc.xyz`** — rejected. The hashed link IS the auth; an additional gate would be friction with no security benefit (link guessing is already infeasible). +- 2026-04-28: **CI/CD automation** — deferred. Manual `pnpm -r build` + `rsync` + `systemctl restart` is fine for a hobby project. Add Gitea Actions later if deploy friction grows. +- 2026-04-28: **Move log / PGN export, post-game replay** — deferred. Announcements are persisted in-game (so the moderator-panel scrollback works); export and replay are post-MVP. +- 2026-04-28: **Public lobby / matchmaking / ratings** — out of scope. This is a private-link game, not a chess site. +- 2026-04-28: **Pre-deploy "server restarting" warning to active players** — stretch goal, not MVP. Mitigation for now: deploy during low-usage windows. +- 2026-04-28: **Client-side AI / hint generation** — explicitly out of scope. Human vs. human only. diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..1cda251 --- /dev/null +++ b/IDEA.md @@ -0,0 +1,48 @@ +# IDEA.md — blind_chess + + +## What is this? +Web-based two player chess game cleanly imlementing the game variation below, but where the physical separation is digial and the moderator is the computer. +Implements existing chess game open source code (research) + +## Original game variation (real world) +2 players play chess, battleship-style, a moderator sits between them syncing boards and announcing if a proposed move is acceptable. Player 1 and player 2 cannot see each other's boards, but the moderator +sees all. +The game depends on the moderator, who needs to understand the “hierarchy” of statements, for example: + + + +That piece no longer exists + +The piece has no legal moves + +Illegal move + +Moving that piece will not help you + +White has moved + +Black has moved and captured + +White is in check + +Black has castled + +White has moved and captured en passant + +The moderator has a full board of his own, and needs to make sure the player boards have pieces of his own color placed correctly + +There are definite strategy points for the players. It is very important that if a player touches a piece, and the moderator is silent, the player must move that piece until a legal move is completed. + +## Problem it solves +This game is very cumbersome in real life and requires 3 people. Digitizing it eliminates all of that. + +## Constraints / preferences +chess.sethpc.xyz -> two buttons (regular chess(vanilla) or blind chess(variaton) -> creates a hashed link for you to send to the other player. Only way to join a game is to follow that hashed link +OR propose a better method for low-friction p2p +Game follows verbage and rules of variation, but the moderator and moderator board are digitized. Each player only sees their pieces. + +## Things to address +Does the sourced version highlight acceptable moves? if so, we should un-highlight illegal moves, and not reveal capture moves. Unlike normal chess, once you touch a piece you must move that piece, except +if that piece has no legal moves. +Highlighting in general should be a per-game toggleable option at game rules start. diff --git a/docs/superpowers/specs/2026-04-28-blind-chess-design.md b/docs/superpowers/specs/2026-04-28-blind-chess-design.md new file mode 100644 index 0000000..d37ab93 --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-blind-chess-design.md @@ -0,0 +1,604 @@ +# blind_chess — Design + +**Date:** 2026-04-28 +**Status:** approved (brainstorming session, 2026-04-28) +**Source:** `IDEA.md`, brainstorm session 2026-04-28 + +## Summary + +Web-based two-player chess where each player sees only their own pieces. The server acts as moderator: it holds the canonical board, validates moves, and announces a small fixed vocabulary of events (illegal move, won't help you, captured, in check, castled, etc.). Two game modes from day one: **vanilla** (full reveal, normal chess) and **blind** (the variation). Modes share one engine; "mode" is a per-player view filter, not a different game. + +## Background + +The physical variation: two players play chess battleship-style, separated so neither sees the opponent's board. A human moderator sits between them, syncs both player boards against a master board, and announces a strictly-ordered vocabulary of events ("That piece no longer exists" → "The piece has no legal moves" → "Moving that piece will not help you" → "Illegal move" → success announcements). It's tactically interesting but cumbersome: it requires three people and a lot of physical setup. + +This project digitizes the moderator. The physical separation becomes WebSocket transport; the master board becomes a server-side `chess.js` instance; the announcement vocabulary becomes a typed enum. + +## Goals + +- A working two-player game playable from any device with a browser. Mobile-first. +- Both vanilla and blind modes shipped together. +- Faithful implementation of the touch-move rule (once you commit to a piece, you must move it unless the moderator releases you). +- Moderator vocabulary captured as an enum, decoupled from display strings, ready for i18n or theming later. +- Hosted at `chess.sethpc.xyz` on the homelab, behind Caddy, in a fresh LXC. + +## Non-goals (MVP) + +- No authentication beyond the hashed game link. Anyone with the link can claim the open slot. +- No spectator mode. The first two arrivals are the players; further requests are rejected. +- No persistent game history, no replay, no PGN export. Server restart loses active games. +- No clocks. Untimed correspondence-style play. +- No matchmaking, ratings, or public lobby. +- No client-side AI / hint generation. The whole game is human vs. human. + +## Decisions (from brainstorming) + +| # | Decision | Why | +|---|---|---| +| 1 | Both modes shipped day-one; mode = view filter on a shared engine | Single engine, marginal extra UI | +| 2 | Node 22 + TypeScript, Fastify + `ws`, Svelte + Vite | Single-language stack; Svelte's reactive stores fit board state; Fastify is right-sized | +| 3 | `chess.js` for rules + custom `geometricMoves` helper for pseudo-legal | chess.js doesn't expose pseudo-legal; ~80 LoC helper, pure function, testable in isolation | +| 4 | "Moving that piece will not help you" = pseudo-legal moves exist but all leave you in check (pin OR unresolved check) | Hierarchy refinement, deterministic from chess.js outputs | +| 5 | Touch-move flow: tap arms (reversible, client-only), drag-start or destination-click commits ("touches") | Faithful to physical game; matches lichess/chess.com muscle memory; arming is purely visual so it's safe to be reversible | +| 6 | Game creation: creator picks side (default random); single-use link; no spectators; link dies with game | Lowest-friction default that matches IDEA.md's intent | +| 7 | Reconnect via opaque `PlayerToken` in `localStorage`, 5-minute grace window | Generous enough for phone hiccups, short enough that abandoned games end | +| 8 | Highlighting (when ON, blind mode) shows all geometrically-reachable squares minus off-board minus own-piece. Rays extend through unseen opponent pieces. Zero opponent info leak. | Highlights become a function of `(piece type, position, own-piece set)` — provably no opponent input | +| 9 | Highlighting (when ON, vanilla mode) shows engine-truth: legal squares with empty/capture distinction | No hiding in vanilla; full reveal is the point | +| 10 | In-memory only; `Map` is the database | Simplest possible; SQLite later if crash recovery becomes desired | +| 11 | New LXC on node-241 behind Caddy CT 600; systemd-managed Node service on port 3000 | Clean isolation; matches existing patterns | +| 12 | Auth = the hashed link itself; no Authentik gate | Friction-minimal; appropriate for a casual game | +| 13 | Pawn promotion via modal (Q/R/B/N); moderator announces the new piece type | Promotion is tactically significant — public info | +| 14 | Auto-detect all draws (stalemate, insufficient material, threefold, 50-move) | Casual-play friendly; no "claim" UI needed | +| 15 | Resign + draw-offer/accept flow standard chess UX | Conventional | +| 16 | Janitor prunes finished games after 30 min idle; active games never expire | Memory hygiene without losing post-game review window | + +Deferred / rejected: + +- Tactical-advice interpretation of "won't help you" (engine evaluation deciding moves are bad) — too subjective, anti-fun. +- Spectator mode for MVP — defer until there's demand. +- SQLite persistence — defer until in-memory loss becomes painful. +- E2E browser tests — protocol tests cover the same ground. + +## Architecture + +``` + Internet + │ + ▼ + ┌─────────────┐ + │ Caddy │ CT 600 (existing) + │ │ TLS, *.sethpc.xyz + │ 192.168.0.185│ + └──────┬──────┘ + │ chess.sethpc.xyz → reverse_proxy + ▼ + ┌────────────────────────┐ + │ blind_chess CT (new) │ new LXC on node-241 + │ Debian, ~512MB RAM │ + │ │ + │ ┌──────────────────┐ │ + │ │ blind-chess.svc │ │ systemd unit + │ │ Fastify + ws │ │ Node 22 LTS + │ │ port 3000 │ │ + │ │ │ │ / → static client + │ │ │ │ /ws → WebSocket upgrade + │ │ │ │ /api/games → POST creates game + │ │ │ │ /api/health → uptime probe + │ └──────────────────┘ │ + └────────────────────────┘ + │ + ▼ + Map (memory only) +``` + +**Process & port summary:** one Node process, one port (3000), one systemd unit. Caddy already terminates TLS, handles HTTP/3, and upgrades WebSockets — no TLS in our service. + +**No DB, no Redis.** Restart drops active games. Acceptable for MVP scope. + +### Repo layout + +``` +blind_chess/ +├── packages/ +│ ├── server/ # Fastify + ws, chess.js, view filter, FSM +│ │ ├── src/ +│ │ │ ├── server.ts # bootstrap, routes +│ │ │ ├── ws.ts # WS upgrade, message dispatch +│ │ │ ├── games.ts # in-memory registry, gameId generation +│ │ │ ├── commit.ts # commit handler FSM (§5.1) +│ │ │ ├── translator.ts # chess.js → ModeratorText enum (§5.2) +│ │ │ ├── view.ts # buildView, the security boundary +│ │ │ ├── validation.ts # zod schemas, rate limiting +│ │ │ └── disconnect.ts # grace-window timers, abandonment +│ │ ├── test/ +│ │ │ ├── unit/ # geometric, translator, view, validation +│ │ │ └── integration/ # real WS, scripted games +│ │ └── package.json +│ ├── client/ # Svelte + Vite +│ │ ├── src/ +│ │ │ ├── routes/ # SvelteKit-style or vanilla +│ │ │ │ ├── +page.svelte # landing (mode selector + create) +│ │ │ │ ├── g/[gameId]/+page.svelte # game view +│ │ │ ├── lib/ +│ │ │ │ ├── Board.svelte +│ │ │ │ ├── ModeratorPanel.svelte +│ │ │ │ ├── CapturedTray.svelte +│ │ │ │ ├── DrawDialog.svelte +│ │ │ │ ├── PromotionDialog.svelte +│ │ │ │ ├── stores/game.ts # WS connection + state +│ │ │ │ └── geometric.ts # client-side highlighting (imports from shared) +│ │ └── package.json +│ └── shared/ # ProtocolTypes, ModeratorText enum, Square, etc. +│ ├── src/ +│ │ ├── protocol.ts +│ │ ├── moderator.ts +│ │ ├── geometric.ts # the pure helper, used both sides +│ │ └── types.ts +│ └── package.json +├── pnpm-workspace.yaml +├── tsconfig.base.json +├── package.json # root scripts +├── docs/ +│ └── superpowers/specs/ +│ └── 2026-04-28-blind-chess-design.md +└── deploy/ + ├── blind-chess.service # systemd unit + └── Caddyfile.snippet # additions for CT 600 +``` + +### Build & deploy (manual for MVP) + +1. `pnpm -r build` on dev box → `packages/server/dist/` + `packages/client/dist/` +2. `rsync` server `dist/` and client `dist/` to the new CT +3. `systemctl restart blind-chess` +4. Add Caddyfile snippet to CT 600, `caddy reload` + +CI/automation can come later. + +## Data model + +```ts +// packages/shared/src/types.ts (excerpt) + +type Color = 'w' | 'b'; +type Mode = 'blind' | 'vanilla'; +type Square = `${'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'}${'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'}`; +type PieceType = 'p' | 'n' | 'b' | 'r' | 'q' | 'k'; +type Piece = { color: Color; type: PieceType }; + +type GameStatus = 'waiting' | 'active' | 'finished'; +type EndReason = 'checkmate' | 'stalemate' | 'resign' | 'draw_agreed' + | 'insufficient' | 'fifty_move' | 'threefold' | 'abandoned'; + +type GameId = string; // ^[a-z0-9]{8}$ — 32 bits, link-bound +type PlayerToken = string; // ^[a-z0-9]{24}$ — 144 bits, server-issued +``` + +```ts +// packages/server/src/state.ts + +interface Game { + id: GameId; + mode: Mode; + highlightingEnabled: boolean; + status: GameStatus; + createdAt: number; + finishedAt?: number; + endReason?: EndReason; + winner?: Color; + + chess: ChessJsInstance; // canonical board + moveHistory: MoveRecord[]; + announcements: Announcement[]; // persistent log; survives until game pruned + + players: { w: PlayerSlot | null; b: PlayerSlot | null }; + + armed: { color: Color; from: Square } | null; // touch-move state + drawOffer: { from: Color; at: number } | null; + disconnectAt: { w?: number; b?: number }; // grace window timestamps +} + +interface PlayerSlot { + token: PlayerToken; + socket: WebSocket | null; // null while disconnected + joinedAt: number; +} + +interface MoveRecord { + ply: number; + by: Color; + from: Square; to: Square; + san: string; + capturedPieceType?: PieceType; + promotion?: 'q'|'r'|'b'|'n'; + flags: { castle?: 'k'|'q'; enPassant?: boolean; check?: boolean; mate?: boolean }; + at: number; +} + +interface Announcement { + ply: number; + text: ModeratorText; + audience: Color | 'both'; + payload?: { promotedTo?: PieceType }; // only set when text is *_promoted + at: number; +} +``` + +### Moderator vocabulary (enum) + +```ts +type ModeratorText = + | 'no_such_piece' // "That piece no longer exists" + | 'no_legal_moves' // pseudo-legal = ∅ + | 'wont_help' // pseudo-legal ≠ ∅, legal = ∅ + | 'illegal_move' + | 'white_moved' | 'black_moved' + | 'white_moved_captured' | 'black_moved_captured' + | 'white_moved_captured_ep' | 'black_moved_captured_ep' + | 'white_castled_kingside' | 'white_castled_queenside' + | 'black_castled_kingside' | 'black_castled_queenside' + | 'white_in_check' | 'black_in_check' + | 'white_promoted' | 'black_promoted' + | 'white_checkmate' | 'black_checkmate' + | 'stalemate' | 'draw_insufficient' | 'draw_fifty' | 'draw_threefold'; +``` + +Display strings live in `packages/client/src/lib/moderator-strings.ts`. Tests assert enum values, not display. + +### Per-player view filter + +```ts +interface BoardView { + pieces: { [square: Square]: Piece }; + toMove: Color; + inCheck: boolean | null; // null when unknown to this viewer + // 'legalMoves' is intentionally absent. The server never sends "your candidate + // squares" — the client computes geometric highlights locally from piece type, + // which leaks zero opponent info. +} + +function buildView(game: Game, viewer: Color): BoardView { + if (game.mode === 'vanilla' || game.status === 'finished') { + return fullBoardView(game, viewer); + } + return ownPiecesOnly(game.chess, viewer); +} +``` + +This is the **single security boundary** for opponent information. Any code path that emits board state to a client must go through `buildView`. Snapshot tests assert that blind-mode views for white never contain `'b'` pieces (and vice versa). + +## WebSocket protocol + +Two transports: + +- `POST /api/games` → REST, creates a game, returns `{ gameId, creatorToken, joinUrl }` +- `wss://chess.sethpc.xyz/ws?game=` → WebSocket, all in-game traffic + +### Client → Server + +```ts +type ClientMessage = + | { type: 'hello'; gameId: GameId; token?: PlayerToken; joinAs?: Color | 'auto' } + | { type: 'commit'; from: Square; to?: Square; promotion?: 'q'|'r'|'b'|'n' } + | { type: 'resign' } + | { type: 'offer-draw' } + | { type: 'respond-draw'; accept: boolean } + | { type: 'pong' }; +``` + +- `hello` reconnects (with `token`) or claims the open slot (with `joinAs`). +- `commit` is the *only* move-related message. `to: undefined` = drag-start (commit to piece, no destination yet); `to: Square` = drag-drop or destination-click. `promotion` required when the move is a pawn reaching the back rank. + +### Server → Client + +```ts +type ServerMessage = + | { type: 'joined'; you: Color | 'spectator-rejected'; token: PlayerToken; + view: BoardView; announcements: Announcement[]; + gameStatus: GameStatus; mode: Mode; highlightingEnabled: boolean } + | { type: 'update'; view: BoardView; newAnnouncements: Announcement[]; + gameStatus: GameStatus; touchedPiece?: Square; + drawOffer?: { from: Color } | null; + endReason?: EndReason; winner?: Color } + | { type: 'peer-status'; color: Color; connected: boolean; graceUntil?: number } + | { type: 'error'; code: ErrorCode; message: string } + | { type: 'ping' }; + +type ErrorCode = + | 'game_not_found' | 'slot_taken' | 'spectators_disabled' + | 'not_your_turn' | 'malformed' | 'promotion_required' + | 'must_move_touched_piece' | 'rate_limited'; +``` + +### Protocol invariants + +1. **Server is the only authority.** Client `commit` is a request. Optimistic animation is allowed but must reconcile to the next `update`. +2. **`view` filtering is server-side, server-only.** Opponent pieces are absent from blind-mode payloads, not encrypted-but-present. +3. **Moderator-vocabulary "errors" come through as `Announcement` on `update`, not as `error` messages.** Errors are reserved for protocol-level failures. +4. **`update` is idempotent.** Replaying the latest `update` produces a correct render. +5. **Per-token rate limiting on `commit`** (10/s, burst 20). + +## Game state machine + +### Commit handler (touch-move FSM) + +``` + receive commit {from, to?} + │ + ▼ + ┌────────── touchedPiece set? ──────────┐ + NO YES + │ │ + ▼ ▼ + Validate piece at `from`: from === touchedPiece? + • own color piece? NO ───► error: must_move_touched_piece + no → no_such_piece YES + • pseudo-legal ≠ ∅? │ + no → no_legal_moves ▼ + • legal moves ≠ ∅? to defined? + no → wont_help NO ───► no-op (idempotent) + YES + All checks passed: │ + set touchedPiece = from ▼ + │ legality check + ▼ LEGAL ───► apply move, clear touched + to defined? ILLEGAL ──► illegal_move (touched stays) + NO ───► silent (touched set, awaiting drop) + YES ───► legality check + LEGAL ───► apply move, clear touched + ILLEGAL ──► illegal_move (touched stays) +``` + +The `no_legal_moves` and `wont_help` checks fire **only on first commit** with that piece. Once the piece is determined to have at least one legal move, `touchedPiece` is set and subsequent failed commits are `illegal_move`. + +```ts +function handleCommit(game: Game, color: Color, msg: CommitMessage): CommitResult { + if (game.chess.turn() !== color) return error('not_your_turn'); + + const touched = game.armed?.color === color ? game.armed.from : null; + + if (touched) { + if (msg.from !== touched) return error('must_move_touched_piece'); + if (!msg.to) return noop(); + return tryMove(game, color, msg); + } + + const piece = game.chess.get(msg.from); + if (!piece || piece.color !== color) return announce('no_such_piece'); + + const pseudo = geometricMoves(piece.type, msg.from, ownSquares(game, color)); + if (pseudo.length === 0) return announce('no_legal_moves'); + + const legal = chessJsLegalMovesFrom(game.chess, msg.from); + if (legal.length === 0) return announce('wont_help'); + + game.armed = { color, from: msg.from }; + + if (!msg.to) return silent(); + return tryMove(game, color, msg); +} + +function tryMove(game, color, { from, to, promotion }): CommitResult { + // Pawn reaching back rank requires the promotion field. Missing it is a + // protocol error (the client must show its modal first), not an illegal + // move. touchedPiece stays — the player retries with promotion picked. + if (isPromotionRequired(game, from, to) && !promotion) { + return error('promotion_required'); + } + + const move = game.chess.move({ from, to, promotion }); + if (!move) return announce('illegal_move'); // touchedPiece STAYS + + game.armed = null; + return appliedMove(translateMove(game, move)); +} +``` + +### Moderator translator + +```ts +function translateMove(game: Game, move: ChessJsMove): Announcement[] { + const out: Announcement[] = []; + const mover = move.color; + const opp = mover === 'w' ? 'b' : 'w'; + const isCap = !!move.captured; + const isEp = move.flags.includes('e'); + const isCast = move.flags.includes('k') || move.flags.includes('q'); + const isProm = !!move.promotion; + + // To opponent + if (isCast) { + const side = move.flags.includes('k') ? 'kingside' : 'queenside'; + out.push(announce(`${mover}_castled_${side}`, opp)); + } else if (isCap && isEp) { + out.push(announce(`${mover}_moved_captured_ep`, opp)); + } else if (isCap) { + out.push(announce(`${mover}_moved_captured`, opp)); + } else { + out.push(announce(`${mover}_moved`, opp)); + } + + if (isProm) out.push(announce(`${mover}_promoted`, opp)); + + // To both + if (game.chess.isCheckmate()) out.push(announce(`${mover}_checkmate`, 'both')); + else if (game.chess.inCheck()) out.push(announce(`${opp}_in_check`, 'both')); + + if (game.chess.isStalemate()) out.push(announce('stalemate', 'both')); + if (game.chess.isInsufficientMaterial()) out.push(announce('draw_insufficient', 'both')); + if (game.chess.isThreefoldRepetition()) out.push(announce('draw_threefold', 'both')); + if (halfMoveClock(game.chess) >= 100) out.push(announce('draw_fifty', 'both')); + + return out; +} +``` + +The capturing player learns the captured piece type via the `view`/`update` (their canonical board updates with the capture animation and the captured-pieces tray gets populated). The opponent gets only the `*_moved_captured` announcement. + +### `geometricMoves` helper + +Pure function. Reads only piece type + from-square + own-piece set. Lives in `packages/shared/src/geometric.ts` so it can run on both server (for `no_legal_moves` check) and client (for highlighting). + +```ts +export function geometricMoves( + piece: Piece, from: Square, ownSquares: Set +): Square[] { + switch (piece.type) { + case 'n': return knightJumps(from).filter(s => !ownSquares.has(s)); + case 'k': return kingNeighbors(from).filter(s => !ownSquares.has(s)); + case 'b': return rays(from, BISHOP_DIRS, ownSquares); + case 'r': return rays(from, ROOK_DIRS, ownSquares); + case 'q': return rays(from, QUEEN_DIRS, ownSquares); + case 'p': return pawnGeometry(from, piece.color, ownSquares); + } +} + +// Crucial: rays stop AT own pieces (excluded), but extend THROUGH any +// unknown squares (which may host opponent pieces). +function rays(from: Square, dirs: Dir[], own: Set): Square[] { + const out: Square[] = []; + for (const [df, dr] of dirs) { + let f = fileOf(from), r = rankOf(from); + while (true) { + f += df; r += dr; + if (!onBoard(f, r)) break; + const sq = square(f, r); + if (own.has(sq)) break; + out.push(sq); + } + } + return out; +} +``` + +Pawn geometry includes forward-1, forward-2 (when from starting rank), and both diagonals — even when the diagonals are empty. Diagonal-to-empty attempts will be rejected as `illegal_move` (which is fine — the player will probe). Castling is handled by `chess.js` as a special king move; not part of `geometricMoves`. + +**Castling and highlighting:** in **vanilla+ON** mode, the highlight set is chess.js's legal-moves output, which includes castling destinations (g1/c1 or g8/c8) when castling is legal. In **blind+ON** mode, castling is *not* highlighted — `geometricMoves` for a king returns only the 8 neighbors. This is a deliberate choice: castling legality depends on opponent piece positions (path through check, opponent pieces between king and rook), and partial highlighting would either leak that info or mislead. The player can still execute castling by manually committing the king two squares toward a rook; the server applies it normally. + +### Hierarchy decision table (six golden tests) + +| # | Scenario | Pseudo-legal | Legal | Expected | +|---|---|---|---|---| +| 1 | Empty square / wrong color | — | — | `no_such_piece` | +| 2 | Knight surrounded by own pawns | ∅ | ∅ | `no_legal_moves` | +| 3 | Pinned bishop, not in check | ≠∅ | ∅ | `wont_help` | +| 4 | In check, knight can't block/capture | ≠∅ | ∅ | `wont_help` | +| 5 | In check, knight can block | ≠∅ | ≠∅ | silent on commit; `illegal_move` on bad destination | +| 6 | Normal piece with options | ≠∅ | ≠∅ | silent on commit | + +## Highlighting rules (full table) + +| Game mode | Toggle | What player sees | +|---|---|---| +| Blind | OFF | Own pieces only. No overlays. Pure moderator-vocabulary game. | +| Blind | ON | Own pieces + green dots on every geometrically-reachable square (excluding off-board and own pieces). Rays extend through unseen opponent pieces. Zero opponent info leak. | +| Vanilla | OFF | Both colors. No overlays. | +| Vanilla | ON | Both colors + green dots (legal empty) + red rings (legal captures). Engine-truth. | + +## Lobby flow + +``` +landing page (chess.sethpc.xyz) + │ + │ user selects mode (blind | vanilla) + │ user picks side (white | black | random) + │ user picks highlighting (on | off) + │ click "Create game" + ▼ +POST /api/games { mode, side, highlightingEnabled } + ▼ +server returns { gameId, creatorToken, joinUrl: chess.sethpc.xyz/g/ } + ▼ +creator's browser: localStorage.setItem(`bc:${gameId}`, creatorToken) + navigate to /g/ + WS connects, hello {gameId, token} + server responds joined { you: , status: 'waiting' } + UI shows "Waiting for opponent — share this link: " + ▼ +opponent opens joinUrl + │ + │ /g/ page loads + │ no localStorage token (first visit) → WS hello {gameId, joinAs: 'auto'} + │ server claims the open color, generates token + ▼ +server responds joined { you: , token: , status: 'active' } +opponent's browser: localStorage.setItem(`bc:${gameId}`, token) + │ + │ server flips game to 'active', broadcasts update to creator + ▼ + game on +``` + +Reconnect: navigating to `/g/` reads `bc:` from localStorage, sends `hello {gameId, token}`. Server matches token to slot, restores socket pointer, replays state. + +## Error handling, security, testing + +### Connection lifecycle + +5-minute grace on disconnect. Reconnect cancels the timer. Grace expiry → `update { gameStatus: 'finished', endReason: 'abandoned', winner: }`. Both-sides simultaneous disconnect: first to expire ends the game, `winner: undefined`. + +Clean tab-close still applies grace (player may be reopening). Only intentional `resign` ends without grace. + +**Same-token, second socket.** If a player opens their gameId in a second tab, both tabs read the same token from `localStorage`. The server enforces one socket per slot: a `hello` with a token already bound to a live socket closes the old socket (sends `close` frame with reason `superseded`) and accepts the new one. Last-connect-wins. The displaced tab shows a "this game is now open in another tab" banner. + +### Security boundaries (each invariant has a test) + +1. **`buildView` is the only egress for board state.** Snapshot tests assert blind-mode white view contains no `'b'` pieces. +2. **Server is the only authority on game state.** Malicious-client integration test: `commit` while not your turn → `error: not_your_turn`. +3. **Touch-move enforcement is server-side.** Bypass-client test: `commit` with different `from` while `touchedPiece` set → `error: must_move_touched_piece`. + +### Input validation & rate limiting + +- All inbound messages parsed via `zod` schemas. Mismatch → `error: malformed`. +- Per-token token-bucket on `commit`: 10/s, burst 20. +- `gameId` strictly `^[a-z0-9]{8}$`. +- `Square` strictly validated against `[a-h][1-8]`. + +### Crash & restart + +- systemd: `Restart=always`, `RestartSec=2s`. +- Client: WS reconnect with exponential backoff, ≤5 retries, then "server went away" message. +- Server restart drops active games (acceptable — see "Non-goals"). + +### Testing strategy + +**Unit (`vitest`, `packages/server` + `packages/shared`):** +- `geometricMoves` — table-driven, every piece type, blocked-by-own cases. +- `translateMove` — canned chess.js move objects + fake instance, assert announcement enums. +- `buildView` — snapshot per-viewer outputs in both modes. +- Hierarchy decision table — all six rows. +- `validateCommit` / zod schemas — fuzz inputs. + +**Integration (`vitest`, real WS):** +- Two test WS clients play scripted games end-to-end: + - Standard opening + - Castling (kingside, queenside, both colors) + - En passant + - Promotion (with and without `promotion` field) + - Resign / draw offer / accept / decline + - Checkmate, stalemate, threefold, 50-move + - Touch-move violation + - Disconnect within grace → reconnect → state replay + - Disconnect through grace → opponent wins by abandonment + +**E2E browser:** out of scope for MVP. Manual testing on phone + desktop. + +### Logging & observability + +- **Pino** structured JSON logging via Fastify default. journald captures stdout. +- `/api/health` → `{ ok: true, activeGames, uptime }`. Wired to Uptime Kuma. +- No metrics/tracing in MVP; cheap to add later (`/api/metrics`, ~30 LoC). + +## Glossary + +- **arm / armed** — client-side state where a piece is tentatively selected; reversible. Visual only. +- **commit / touched** — irreversible declaration that the player will move this piece. Server-side state. Triggered by drag-start or destination-click. +- **moderator vocabulary** — fixed enum of announcements the server emits to communicate game events. +- **pseudo-legal moves** — moves that satisfy a piece's movement geometry, ignoring whether they leave the king in check. +- **legal moves** — pseudo-legal moves filtered for king safety. What `chess.js` returns from `moves()`. +- **geometric moves** — pseudo-legal-ish moves computed from piece type + position + own-piece set only (no opponent input). Used for highlighting and the `no_legal_moves` check. +- **view filter** — server-side function that produces a per-player `BoardView` from the canonical `Game`. The single security boundary for opponent information. + +## Open items (resolved before implementation) + +None at spec close. All architectural and gameplay questions resolved during the brainstorming session.