Files
blind_chess/docs/superpowers/specs/2026-05-18-table-fidelity-features-design.md
T
claude (blind_chess) f8faa11b6d docs: design spec for table-fidelity feature batch
Three features requested by Andrew Freiberg (physical-game player) and
refined by Seth, bringing digital blind chess closer to the physical
table:

1. Moderator announces every move and attempted move to both players
   (widen announcement audience to 'both'; suppress bot retry churn).
2. Running capture tally (server-derived per-viewer protocol field).
3. Phantom opponent pieces — a client-local, drag-and-drop opponent-model
   overlay, blind mode only, never sent to the server.

Spec only; no implementation. Phased: F1+F2 then F3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:52:00 -04:00

19 KiB
Raw Blame History

Table-Fidelity Features — Design Spec

Three features that bring digital blind chess closer to the physical-table experience, requested by Andrew Freiberg (an experienced physical-game player) and refined by Seth.

  • Date: 2026-05-18
  • Status: Approved (brainstorm), pending spec review
  • Project: blind_chess
  • Supersedes/extends: 2026-04-28-blind-chess-design.md (moderator vocabulary, view filter, FSM). No prior decision is reversed; one (audience filtering of move events) is deliberately widened — see Feature 1.

Motivation

From Andrew's email (the physical game uses a human moderator and three people):

  1. "Can you make it so the moderator announces all moves or attempted moves, white and black? One of the things a player listens for is what the moderator says when it is the other player's turn."
  2. "A running tabulation so you can see how many pieces you have captured. Normally when you play, you set up the opponent's pieces on your board and remove them as you make a capture to keep track."

And Seth's framing of the third: "You move the other players' pieces at will to create a model 'guess' of where their pieces are... move the opponent's pieces anywhere you want, including off the board."

These map to three features. They are independent in code but share one theme: making the digital game as faithful to the physical table as Andrew's real-world experience of it.

Scope at a glance

# Feature Where Size
1 Moderator announces every move & attempted move, to both players server (+ tiny client) small
2 Running capture tally server + client panel smallmedium
3 Phantom opponent pieces (private opponent-model overlay) client only medium

Phasing: one spec, one plan, implemented in two increments — Increment 1 = Features 1 + 2 (server-centric, shippable alone); Increment 2 = Feature 3 (the larger client build).


Feature 1 — Moderator announces everything, to both players

Current behaviour

Announcement carries an audience field ('w' | 'b' | 'both'), filtered in exactly two places: the server's broadcastSinceLast (ws.ts) and the client's ModeratorPanel.

  • Move events (white_moved, *_moved_captured, *_castled_*, *_promoted) — emitted with audience: opp. Only the opponent of the mover sees them.
  • Attempted-move errors (no_such_piece, no_legal_moves, wont_help, illegal_move) — emitted with audience: actor. Only the player who made the attempt sees them.
  • State changes (*_in_check, *_checkmate, draws) and resign/draw/abandon — already audience: 'both'.

So a player does not hear what the moderator says about the opponent's failed attempts — exactly the channel Andrew listens for at the table.

Change

Both move events and attempted-move errors become audience: 'both'. After this change every announcement is 'both'; the audience field becomes uniformly 'both' but is retained as the moderator-channel egress-control hook (the filtering code in ws.ts and ModeratorPanel stays — it is the security boundary, now a pass-through).

Concretely:

  • translator.ts translateMove — the six move-event announce(...) calls change their audience argument from opp to 'both'.
  • commit.ts announceWithannounce(text, color, ply) becomes announce(text, 'both', ply).

No protocol change, no enum change.

Client: labelling attempted-move lines

The four error enums carry no colour (wont_help, not white_wont_help). With the announcement now shared, the panel must say whose attempt it was.

It can be derived for free: an attempted move only ever happens on the actor's turn, and an error announcement's ply is chess.history().length captured before the move applies. Therefore ply parity is the actor: ply % 2 === 0 → White attempted, odd → Black. (Move events already encode colour in the enum text and need no parity prefix.)

The client (ModeratorPanel / moderator-strings) prefixes the four error texts with "White — " / "Black — " derived from ply parity. The current alarm-red .err styling on those four entries is replaced with a neutral/muted "moderator info" style — with a shared transcript they are commentary, not "you did something wrong".

Bot retry suppression

The blind Casual bot searches for a legal move by retrying rejected candidates inside one decision cycle (BotDriver, RETRY_CAP = 25). Each rejection runs through announceWith, which pushes one error announcement onto game.announcements. With those now 'both', the human opponent would see the bot's internal search as up to 25 lines of moderator spam.

Fix, entirely within bot/driver.ts: in dispatch, on the 'announce' retry branch, pop the just-pushed announcement off game.announcements before returning { kind: 'retry' }. This is safe because:

  • announceWith pushes exactly one announcement and the driver receives it as result.announcements[0]; nothing mutates game.announcements in between, so it is the last element. Pop with an identity guard (if (last === result.announcements[0]) pop()).
  • The whole bot decision cycle runs inside pokeBot(game) and completes before ws.ts calls broadcastSinceLast — intermediate announcements are removed before any broadcast ever runs.
  • The bot tracks its own rejections via attemptHistory (explicitly passed), not via newAnnouncements, so popping does not affect the bot's logic.

Only the bot's final committed move announcement survives and is broadcast (that is Feature 1 working: the human hears "Black has moved"). A bot that exhausts retries still resigns; the human sees the resignation, not the 25 fumbles.

Human probes are not suppressed: a person tries 13 pieces per turn, naturally bounded — broadcasting those is the feature.

Information-channel note (deliberate)

Feature 1 genuinely widens what blind mode reveals. Hearing "won't help you" on the opponent's turn tells you they are pinned or otherwise constrained; no_legal_moves tells you they touched a fully-boxed-in piece. This is the intended moderator channel and it is faithful to the physical game (Andrew, an experienced physical-game player, explicitly asked for it). It is recorded here as a conscious, authorised reduction in blindness — not a leak through an illegitimate side channel. buildView and geometric.ts (the zero-leak core) are untouched.

Files

  • packages/server/src/translator.ts — audience of move events.
  • packages/server/src/commit.ts — audience in announceWith.
  • packages/server/src/bot/driver.ts — pop intermediate retry rejections.
  • packages/client/src/lib/moderator-strings.ts / packages/client/src/lib/ModeratorPanel.svelte — actor prefix via ply parity; neutral styling for attempted-move lines.

Tests

  • Unit (translator): move events carry audience: 'both'.
  • Unit (commit-fsm): no_such_piece / no_legal_moves / wont_help / illegal_move carry audience: 'both'.
  • Unit (driver): after a decision cycle that incurs ≥1 retry, only the final move's announcement(s) remain in game.announcements; intermediate rejections are absent.
  • Integration (ai-game-casual): the human side receives the bot's final move announcement and no retry-rejection announcements.

Feature 2 — Running capture tally

What the player sees

A read-only panel beside the board:

  • Primary line — "You've captured:" followed by glyphs of the opponent pieces you have taken, with a count, e.g. ♟ ♟ ♞ (3).
  • Secondary muted line — "Lost:" followed by glyphs of your pieces the opponent has taken, e.g. ♙ ♗ (2).

(Andrew asked specifically for captures; losses are free to compute and complete the picture. Seth may drop the "Lost" line at spec review.)

Why this needs the server

In blind mode the capturing client cannot see what it captured — opponent pieces are filtered out of its BoardView. The captured piece's type must come from the server. This is the same single-piece-of-history reveal the physical moderator gives you when you take a piece.

Data model

MoveRecord already records by: Color and capturedPieceType?: PieceType for every move (state.ts). The tally is a pure derivation of game.moveHistory.

New shared type (packages/shared/src/types.ts):

export type PieceTally = Partial<Record<PieceType, number>>;

New field on the joined and update server messages (packages/shared/src/protocol.ts):

captures: { byYou: PieceTally; byOpponent: PieceTally };

captures is sent on every update (it is tiny and keeps update idempotent — replaying the latest update still renders correctly, matching the existing protocol decision).

Server computation

New module packages/server/src/captures.ts:

function captureTally(game, viewer): { byYou: PieceTally; byOpponent: PieceTally }

Iterates game.moveHistory; for each record with capturedPieceType, increments byYou if by === viewer, else byOpponent. En-passant captures are included automatically (capturedPieceType is 'p').

ws.ts includes captures: captureTally(game, color) in the joined payload (onHello) and in update payloads (sendUpdateTo).

Client

  • game.svelte.ts store gains a captures field, set from joined and update.
  • New component packages/client/src/lib/CaptureTally.svelte, rendered in the side panel of Game.svelte near ModeratorPanel. Reuses pieces.ts pieceGlyph. byYou glyphs render in the opponent's colour (they are opponent pieces); byOpponent glyphs render in your colour.

Modes

Built for both modes. In vanilla it is a simple scoreboard; in blind it is the load-bearing feature. The bot ignores captures; a future ReconBrain may consume it (Phase 2, out of scope here).

Files

  • packages/shared/src/types.tsPieceTally.
  • packages/shared/src/protocol.tscaptures on joined / update.
  • packages/server/src/captures.ts — new, captureTally.
  • packages/server/src/ws.ts — include captures in joined / update.
  • packages/client/src/lib/stores/game.svelte.ts — store field.
  • packages/client/src/lib/CaptureTally.svelte — new component.
  • packages/client/src/lib/Game.svelte — mount the panel.

Tests

  • Unit (captures): captureTally returns correct per-viewer counts for a moveHistory containing captures by both colours, including en passant.
  • Client component verified manually (no client test harness — see Feature 3).

Feature 3 — Phantom opponent pieces

A private opponent-model overlay on the player's own board: the digital form of "set up the opponent's pieces on your board and move/remove them to keep track." Blind mode only — pointless in vanilla, where real opponent pieces are visible.

Behaviour (the manual model, per Seth's decision)

  • Seeded once at game start with the opponent's 16 pieces on their standard home squares — saves the manual initial setup.
  • Fully manual thereafter. Drag any phantom anywhere; drag it off the board to remove it; place fresh phantoms from an always-available palette of the six piece types. No count limits, no automation, no auto-removal on capture. Editable at any time, including the opponent's turn.
  • The capture tally (Feature 2) is a separate read-only counter — it is not coupled to the phantom layer.
  • Phantoms are visually distinct from real pieces (translucent, dashed outline) and use the opponent's colour.
  • A phantom cannot occupy a square holding one of your real pieces (your real pieces are known truth). Other squares are all valid — overlapping where the opponent might really be is the entire point.
  • On game-over the board reveals all real pieces; the phantom overlay and palette are hidden so the reveal is clean.

The security invariant (load-bearing)

The phantom layer is 100% client-local. It is never serialized to the wire, never sent to the server, never seen by the opponent. It contains zero real opponent information by construction — it is the player's own fiction.

To make this auditable, the phantom layer gets its own store, separate from the server-synced game.svelte.ts:

New store packages/client/src/lib/stores/phantoms.svelte.ts. A reviewer confirms "phantoms never leak" by verifying this store is never read in any send(...) / commit(...) path. No ClientMessage variant carries phantom data.

buildView and geometric.ts are untouched.

Data model

  • Phantom state: Partial<Record<Square, Piece>> — at most one phantom per square; Piece.color is always the opponent's colour. Placing on an occupied phantom square replaces.
  • Store operations: place(sq, type), move(from, to), remove(sq), clear(), loadForGame(gameId, you).
  • Persistence: localStorage, key bc:phantoms:<gameId>, value = JSON of the phantom map. Survives reload / reconnect (important on phones).
  • Seeding: on first load of a blind game (no localStorage key present), seed the 16 opponent pieces at standard start squares for the opponent colour (you === 'w' ? 'b' : 'w') and persist immediately. The presence of the key thereafter means "already seeded — load, do not re-seed", so a reload never wipes the player's edits.
  • On gameStatus === 'finished', clear the localStorage key (avoids unbounded accumulation across games).

The pure transformation logic — standard start squares for a colour, place/move/remove on a map, (de)serialization — is extracted into a plain (non-.svelte) module so it can be unit-tested. phantoms.svelte.ts is the thin reactive wrapper. (The plan decides whether to stand up a vitest config in the client package, which currently has none, or host the pure module in packages/shared.)

Interaction — drag-and-drop (approved option A)

Drag-and-drop via pointer events (pointerdown / pointermove / pointerup) so it works for both mouse and touch. Real moves stay click-to-move (the touch-move FSM is unchanged — the deferred decision against drag-and-drop for real moves still stands; F3's drag is phantom-only).

  • Move a phantom: pointerdown on a phantom → once the pointer moves past a small threshold (~6 px) it is a drag; a drag image follows the pointer. pointerup over another square → move the phantom there; over a square with your real piece → invalid, snap back; outside the board → remove the phantom.
  • Place from palette: pointerdown on a palette piece → drag → pointerup over a board square free of your real piece → place a phantom of that type.
  • Tap vs drag: a pointerdown+pointerup with no movement past the threshold is not a phantom action — it is forwarded to the board's normal onSquareClick, so you can still arm/commit a real move onto a square you have a phantom guess on (e.g. to capture there). A drag past the threshold stopPropagations so the underlying square click does not also fire.

This isolation — tap → real move, drag → phantom — means phantom editing never blocks the live game's move path and needs no mode toggle.

Rendering & components

  • The phantom layer is rendered within Board.svelte as an additional styled layer in each grid cell (alignment with real squares is then free). Board.svelte owns the pointer-event handling and the tap-vs-drag disambiguation, because that decision cannot be cleanly split across components. Board.svelte remains prop-driven: it receives phantom data and onPhantomMove / onPhantomPlace / onPhantomRemove callbacks.
  • New component packages/client/src/lib/PhantomPalette.svelte — the six-type palette, rendered beside the board on desktop / below on mobile; source of palette→board drags.
  • Game.svelte wires the phantoms store to Board and PhantomPalette, and gates the whole phantom UI on mode === 'blind' && gameStatus === 'active'.
  • Phantom styling lives in app.css / component styles (translucent, dashed).

Out of scope for v1

Highlighting ignores phantoms — the geometric highlight stays a function of your real pieces only. Letting bishop/rook rays stop at phantom pieces would be information-safe (phantoms hold zero real opponent data) and is a reasonable future enhancement, but it is not in this spec.

Files

  • New: packages/client/src/lib/stores/phantoms.svelte.ts, packages/client/src/lib/PhantomPalette.svelte, a pure phantom-model module (location per plan), optionally a small pointer-drag helper.
  • Modified: packages/client/src/lib/Board.svelte (phantom layer + drag + tap-vs-drag), packages/client/src/lib/Game.svelte (mount palette, wire store, blind+active gating), packages/client/src/app.css (phantom styles).
  • No server or shared changes for Feature 3 (unless the pure model module is hosted in packages/shared).

Tests

Feature 3 is client-only and the project currently has no client test harness (the 78 existing tests are all shared + server). The pure phantom-model logic (seed squares, place/move/remove, (de)serialization) is unit-tested; the drag interaction and rendering are verified manually on phone + desktop. The plan decides the test-infra approach.


Architecture & invariants summary

  • Feature 1 widens the moderator channel: every Announcement becomes audience: 'both'. The field and its filtering are retained as the egress control. This is a deliberate, authorised increase in shared information, faithful to the physical game.
  • Feature 2 adds one server-derived, per-viewer protocol field (captures). Capture types stay out of the ModeratorText enum — announcements remain a pure event vocabulary; the tally is structured data.
  • Feature 3 adds a client-local-only layer that never reaches the wire, isolated in its own store for auditability.
  • The zero-leak core — buildView and geometric.ts — is not touched by any of the three features.

Phasing

Increment Contents Independently shippable
1 Features 1 + 2 Yes — server + a read-only client panel
2 Feature 3 Yes — client phantom layer

One implementation plan; tasks ordered so Increment 1 completes (and can deploy) before Increment 2 begins.

Out of scope / explicitly rejected

  • Smart-tracker phantom model (auto-removal on capture, promotion bookkeeping, constrained opponent army) — rejected by Seth in favour of the manual model.
  • Phantom layer in vanilla mode — pointless; excluded.
  • Drag-and-drop for real moves — still deferred (DECISIONS.md). F3's drag is phantom-only.
  • Highlighting interacting with phantoms — safe future enhancement, not v1.
  • Capture tally feeding bot decisions — bot ignores it; possible Phase-2 ReconBrain input.
  • Sending phantom state to the server / persisting it server-side — would break the security invariant; never.

Open questions

None outstanding. Resolved during brainstorm:

  • Phantom model: manual, seeded once, no automation, unlimited placement, editable anytime (Seth).
  • Phantom interaction: drag-and-drop, tap-vs-drag disambiguation, no mode toggle (Seth — option A).
  • Feature 2 "Lost" secondary line: included by default; Seth may drop it at spec review.