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>
19 KiB
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 (audiencefiltering of move events) is deliberately widened — see Feature 1.
Motivation
From Andrew's email (the physical game uses a human moderator and three people):
- "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."
- "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 | small–medium |
| 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 withaudience: opp. Only the opponent of the mover sees them. - Attempted-move errors (
no_such_piece,no_legal_moves,wont_help,illegal_move) — emitted withaudience: actor. Only the player who made the attempt sees them. - State changes (
*_in_check,*_checkmate, draws) and resign/draw/abandon — alreadyaudience: '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.tstranslateMove— the six move-eventannounce(...)calls change their audience argument fromoppto'both'.commit.tsannounceWith—announce(text, color, ply)becomesannounce(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:
announceWithpushes exactly one announcement and the driver receives it asresult.announcements[0]; nothing mutatesgame.announcementsin 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 beforews.tscallsbroadcastSinceLast— intermediate announcements are removed before any broadcast ever runs. - The bot tracks its own rejections via
attemptHistory(explicitly passed), not vianewAnnouncements, 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 1–3 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 inannounceWith.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 viaplyparity; neutral styling for attempted-move lines.
Tests
- Unit (
translator): move events carryaudience: 'both'. - Unit (
commit-fsm):no_such_piece/no_legal_moves/wont_help/illegal_movecarryaudience: 'both'. - Unit (
driver): after a decision cycle that incurs ≥1 retry, only the final move's announcement(s) remain ingame.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.tsstore gains acapturesfield, set fromjoinedandupdate.- New component
packages/client/src/lib/CaptureTally.svelte, rendered in the side panel ofGame.sveltenearModeratorPanel. Reusespieces.tspieceGlyph.byYouglyphs render in the opponent's colour (they are opponent pieces);byOpponentglyphs 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.ts—PieceTally.packages/shared/src/protocol.ts—capturesonjoined/update.packages/server/src/captures.ts— new,captureTally.packages/server/src/ws.ts— includecapturesinjoined/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):captureTallyreturns correct per-viewer counts for amoveHistorycontaining 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 anysend(...)/commit(...)path. NoClientMessagevariant carries phantom data.
buildView and geometric.ts are untouched.
Data model
- Phantom state:
Partial<Record<Square, Piece>>— at most one phantom per square;Piece.coloris 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, keybc:phantoms:<gameId>, value = JSON of the phantom map. Survives reload / reconnect (important on phones). - Seeding: on first load of a blind game (no
localStoragekey 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 thelocalStoragekey (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:
pointerdownon a phantom → once the pointer moves past a small threshold (~6 px) it is a drag; a drag image follows the pointer.pointerupover 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:
pointerdownon a palette piece → drag →pointerupover a board square free of your real piece → place a phantom of that type. - Tap vs drag: a
pointerdown+pointerupwith no movement past the threshold is not a phantom action — it is forwarded to the board's normalonSquareClick, 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 thresholdstopPropagations 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.svelteas an additional styled layer in each grid cell (alignment with real squares is then free).Board.svelteowns the pointer-event handling and the tap-vs-drag disambiguation, because that decision cannot be cleanly split across components.Board.svelteremains prop-driven: it receives phantom data andonPhantomMove/onPhantomPlace/onPhantomRemovecallbacks. - 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.sveltewires thephantomsstore toBoardandPhantomPalette, and gates the whole phantom UI onmode === '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
Announcementbecomesaudience: '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 theModeratorTextenum — 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 —
buildViewandgeometric.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.