Files
blind_chess/docs/superpowers/specs/2026-04-28-blind-chess-design.md
2026-04-28 10:53:26 -04:00

29 KiB

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<gameId, Game> 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<gameId, Game>   (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

// 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
// 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)

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

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=<gameId> → WebSocket, all in-game traffic

Client → Server

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

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.

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

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).

export function geometricMoves(
  piece: Piece, from: Square, ownSquares: Set<Square>
): 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>): 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/<gameId> }
        ▼
creator's browser: localStorage.setItem(`bc:${gameId}`, creatorToken)
                   navigate to /g/<gameId>
                   WS connects, hello {gameId, token}
                   server responds joined { you: <creator's color>, status: 'waiting' }
                   UI shows "Waiting for opponent — share this link: <joinUrl>"
        ▼
opponent opens joinUrl
        │
        │ /g/<gameId> page loads
        │ no localStorage token (first visit) → WS hello {gameId, joinAs: 'auto'}
        │ server claims the open color, generates token
        ▼
server responds joined { you: <opposite color>, token: <new>, 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/<gameId> reads bc:<gameId> 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: <other> }. 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.