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>
This commit is contained in:
claude (blind_chess)
2026-05-18 18:52:00 -04:00
parent b01f324c3b
commit f8faa11b6d
@@ -0,0 +1,435 @@
# 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` `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 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`):
```ts
export type PieceTally = Partial<Record<PieceType, number>>;
```
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<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
`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.