# 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 | 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 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` `announceWith` — `announce(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 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 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. - The `driver` unit test is the chosen coverage for the bot-suppression path: it drives the real `BotDriver` → `handleCommit` → `announceWith` pipeline, so it verifies suppression at the commit-path level. A separate `ai-game-casual` WebSocket integration test was considered and dropped — it would only additionally exercise the trivial `broadcastSinceLast` pass-through filter, for materially more harness complexity. --- ## 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`): ```ts export type PieceTally = Partial>; ``` New field on the `joined` and `update` server messages (`packages/shared/src/protocol.ts`): ```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`: ```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.ts` — `PieceTally`. - `packages/shared/src/protocol.ts` — `captures` 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>` — 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:`, 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 `stopPropagation`s 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.