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.xyzon 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)
pnpm -r buildon dev box →packages/server/dist/+packages/client/dist/rsyncserverdist/and clientdist/to the new CTsystemctl restart blind-chess- 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' };
helloreconnects (withtoken) or claims the open slot (withjoinAs).commitis the only move-related message.to: undefined= drag-start (commit to piece, no destination yet);to: Square= drag-drop or destination-click.promotionrequired 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
- Server is the only authority. Client
commitis a request. Optimistic animation is allowed but must reconcile to the nextupdate. viewfiltering is server-side, server-only. Opponent pieces are absent from blind-mode payloads, not encrypted-but-present.- Moderator-vocabulary "errors" come through as
Announcementonupdate, not aserrormessages. Errors are reserved for protocol-level failures. updateis idempotent. Replaying the latestupdateproduces a correct render.- 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)
buildViewis the only egress for board state. Snapshot tests assert blind-mode white view contains no'b'pieces.- Server is the only authority on game state. Malicious-client integration test:
commitwhile not your turn →error: not_your_turn. - Touch-move enforcement is server-side. Bypass-client test:
commitwith differentfromwhiletouchedPieceset →error: must_move_touched_piece.
Input validation & rate limiting
- All inbound messages parsed via
zodschemas. Mismatch →error: malformed. - Per-token token-bucket on
commit: 10/s, burst 20. gameIdstrictly^[a-z0-9]{8}$.Squarestrictly 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
promotionfield) - 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.jsreturns frommoves(). - 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_movescheck. - view filter — server-side function that produces a per-player
BoardViewfrom the canonicalGame. 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.