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:
@@ -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 | 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.
|
||||
- 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.
|
||||
Reference in New Issue
Block a user