init: scaffold duplicate_chess project and design spec
Local browser sandbox for "duplicate chess" — a four-player coupled-board chess variant invented by Andrew Freiberg. Scaffold per CREATE_PROJECT.md plus the approved design spec from this session's brainstorming. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
# Duplicate Chess — Design Spec
|
||||
|
||||
**Date:** 2026-05-19
|
||||
**Status:** Approved (brainstorming complete); ready for implementation planning.
|
||||
**Author:** Claude + Seth, from a brainstorming session based on the inventor
|
||||
conversation in `blind_chess/USERFILES/4-person-chess.txt`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Duplicate chess is a four-player chess variant invented by Andrew Freiberg. This
|
||||
project is a **local, single-operator, browser-based sandbox/visualizer** for it —
|
||||
the digital equivalent of what `blind_chess` did for blind chess. One operator
|
||||
drives all four players; the tool enforces the rules, renders the state, and shows
|
||||
*why* moves are or are not legal. Its purpose is comprehension: the inventor's stated
|
||||
position is that the variant cannot be understood from prose, only from seeing it
|
||||
played.
|
||||
|
||||
**Key property — perfect information.** Unlike blind chess, duplicate chess hides
|
||||
nothing. Every player sees all four boards. There is therefore no view filter and no
|
||||
trusted server boundary: the whole engine and UI run client-side in one app.
|
||||
|
||||
---
|
||||
|
||||
## 2. The variant — rules
|
||||
|
||||
### 2.1 Setup
|
||||
|
||||
- Four players: **North, South, East, West**.
|
||||
- Four boards, one between each adjacent pair of compass points: **NW, NE, SW, SE**.
|
||||
- North and South play **White**; East and West play **Black**.
|
||||
- Each player controls one colour on **two** boards:
|
||||
|
||||
| Board | White player | Black player |
|
||||
|-------|--------------|--------------|
|
||||
| NW | North | West |
|
||||
| NE | North | East |
|
||||
| SW | South | West |
|
||||
| SE | South | East |
|
||||
|
||||
- Every board starts from the standard chess position.
|
||||
|
||||
### 2.2 Turn order and the synchronized move
|
||||
|
||||
- Turn order is **N → S → E → W**, repeating.
|
||||
- On your turn you make **one move**, applied **identically to both of your boards**
|
||||
(same from-square, same to-square, same promotion piece).
|
||||
- A move is **legal only if it is legal on both** of your boards. If it is not legal
|
||||
on both, you may not play it.
|
||||
- "Identical" means identical algebraic coordinates. The two boards may differ in
|
||||
what the move *does* (a capture on one board, a quiet move on the other) — that is
|
||||
the normal source of divergence.
|
||||
|
||||
### 2.3 Ghosts
|
||||
|
||||
- When one of your pieces is captured on one board, its **twin** on your other board
|
||||
becomes a **ghost**: it can never move again, because no synchronized move exists
|
||||
for it (its counterpart is gone).
|
||||
- A ghost is **not removed**. It still occupies its square, blocks lines, defends
|
||||
squares, can produce a discovered check, can restrict enemy king movement, and can
|
||||
itself be captured (if that capture is a legal synchronized move for the capturing
|
||||
player).
|
||||
- Ghost status is a **one-way, three-state lifecycle** for every original piece-pair:
|
||||
(1) both twins alive and moving in lockstep → (2) one captured, one ghost → (3)
|
||||
both gone. There is no recursion: once a twin is captured there is no surviving
|
||||
counterpart to generate further ghosts.
|
||||
|
||||
### 2.4 Check, checkmate, stalemate
|
||||
|
||||
- Each individual board, viewed in isolation, is always a legal game of orthodox
|
||||
chess. The single exception is the definition of checkmate (below).
|
||||
- **Checkmate** = a player to move is in check on at least one of their boards and
|
||||
has **no synchronized legal move**. A board viewed alone might show an escape, but
|
||||
if that escape cannot be duplicated on the other board, the player is mated.
|
||||
- **Stalemate** = a player to move is **not** in check and has no synchronized legal
|
||||
move.
|
||||
|
||||
### 2.5 Special moves
|
||||
|
||||
- **Castling**: allowed only if legal on both of the player's boards.
|
||||
- **Promotion**: a synchronized pawn advance to the last rank promotes on both
|
||||
boards, necessarily to the **same piece**.
|
||||
- **En passant**: handled by the general rule — the move is legal iff the identical
|
||||
`(from,to)` is legal on both boards; it may be an en-passant capture on one board
|
||||
and a different (or illegal) move on the other.
|
||||
|
||||
### 2.6 Result
|
||||
|
||||
- The winner is the player who delivers checkmate; the loser is the player mated;
|
||||
the other two draw. It is possible for everyone to draw.
|
||||
- See §6 for the **provisional** rulings on cases the inventor conversation left
|
||||
underspecified.
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
A **single Vite + Svelte 5 + TypeScript** application. No server, no pnpm workspace.
|
||||
|
||||
```
|
||||
duplicate_chess/
|
||||
src/
|
||||
engine/ pure TypeScript, DOM-free, vitest-tested
|
||||
boards.ts board/player/turn-order constants and maps
|
||||
game.ts DuplicateGame: state, move application, history
|
||||
legality.ts synchronized-move intersection
|
||||
ghosts.ts ghost derivation
|
||||
endgame.ts checkmate / stalemate / draw detection
|
||||
notation.ts coordinate notation, save/load JSON
|
||||
types.ts shared engine types
|
||||
lib/ Svelte 5 components
|
||||
Compass.svelte the four-board pinwheel
|
||||
Board.svelte one board (rotatable, click-to-move, highlights)
|
||||
Panel.svelte turn indicator, move log, legend, controls
|
||||
PromotionDialog.svelte
|
||||
stores/game.svelte.ts reactive wrapper over the engine
|
||||
App.svelte
|
||||
main.ts
|
||||
```
|
||||
|
||||
- `chess.js` provides per-board orthodox chess: move generation, application,
|
||||
check detection, FEN.
|
||||
- The **engine is the single source of truth**. The UI never computes legality; it
|
||||
calls the engine and renders the result.
|
||||
- The engine is DOM-free so it is unit-testable and liftable into a package if a
|
||||
networked four-player version is ever built.
|
||||
|
||||
---
|
||||
|
||||
## 4. The engine
|
||||
|
||||
### 4.1 The core insight — intersection
|
||||
|
||||
The coupled game reduces to an intersection. Hold four `chess.js` instances. On
|
||||
player `P`'s turn:
|
||||
|
||||
```
|
||||
movesA = chess[boardA].moves({ verbose: true }) // P's moves on board A
|
||||
movesB = chess[boardB].moves({ verbose: true }) // P's moves on board B
|
||||
synced = movesA ∩ movesB keyed by (from, to, promotion)
|
||||
```
|
||||
|
||||
`synced` **is** `P`'s legal move set. Three otherwise-hard rules need no special
|
||||
code:
|
||||
|
||||
- **Ghosts cannot move** — a ghost on board A has no twin on board B, so no move
|
||||
from its square can appear in `synced`.
|
||||
- **Checkmate** — `synced` empty *and* `P` in check on ≥1 board. A board showing an
|
||||
un-synchronizable escape is handled automatically, because that escape is not in
|
||||
`synced`.
|
||||
- **En passant / castling divergence** — same `(from,to[,promotion])` or it is
|
||||
simply absent from `synced`.
|
||||
|
||||
The turn order N→S→E→W also gives every individual board a clean White-then-Black
|
||||
alternation, so each `chess.js` instance stays internally consistent and
|
||||
`chess.moves()` always returns the moves of the player whose global turn it is.
|
||||
|
||||
### 4.2 Constants (`boards.ts`)
|
||||
|
||||
```
|
||||
BOARDS = ['NW','NE','SW','SE']
|
||||
PLAYERS = ['N','S','E','W'] // also the turn order
|
||||
PLAYER_BOARDS = { N:['NW','NE'], S:['SW','SE'], E:['NE','SE'], W:['NW','SW'] }
|
||||
PLAYER_COLOR = { N:'w', S:'w', E:'b', W:'b' }
|
||||
BOARD_PLAYERS = { NW:{w:'N',b:'W'}, NE:{w:'N',b:'E'},
|
||||
SW:{w:'S',b:'W'}, SE:{w:'S',b:'E'} }
|
||||
```
|
||||
|
||||
### 4.3 State and the move list
|
||||
|
||||
The authoritative state is an **ordered list of synchronized moves**:
|
||||
|
||||
```
|
||||
history: { player: Player, from: Square, to: Square, promotion?: PieceSymbol }[]
|
||||
```
|
||||
|
||||
`replayTo(n)` builds four fresh `chess.js` boards and applies the first `n` history
|
||||
entries (each entry applied to its player's two boards). This single function powers
|
||||
construction, **undo** (`replayTo(history.length - 1)`), and **history scrubbing**
|
||||
(`replayTo(k)` for view-only display). Making a new move while scrubbed truncates
|
||||
history after the scrub point — standard behaviour.
|
||||
|
||||
`currentPlayer = PLAYERS[history.length % 4]`.
|
||||
|
||||
### 4.4 Legality (`legality.ts`)
|
||||
|
||||
- `legalSyncedMoves(player) → Move[]` — the intersection from §4.1.
|
||||
- For the UI's triple-highlight, the engine also exposes, for a grabbed square `s`
|
||||
belonging to the current player: `movesA.from(s)`, `movesB.from(s)`, and the
|
||||
`synced` subset from `s`. The UI renders the `synced` subset as **playable**
|
||||
(green) on both boards and the board-local remainder as **legal-here-only**
|
||||
(grey). Grabbing a ghost therefore visibly yields zero playable moves.
|
||||
|
||||
### 4.5 Ghosts (`ghosts.ts`)
|
||||
|
||||
Invariant: a player's **non-ghost** pieces always occupy identical squares on both
|
||||
their boards (they move in lockstep; a ghost is exactly a piece whose lockstep
|
||||
broke). Therefore:
|
||||
|
||||
> A piece of player `P`'s colour on board A at square `s` is a **ghost** iff board B
|
||||
> (P's other board) has no `P`-colour piece at `s`.
|
||||
|
||||
`ghosts() → { board, square }[]` over all four players. Used for rendering only;
|
||||
legality already excludes ghost moves via the intersection.
|
||||
|
||||
### 4.6 Endgame (`endgame.ts`)
|
||||
|
||||
After each move, evaluate the next player `P`:
|
||||
|
||||
- `synced` non-empty → game continues.
|
||||
- `synced` empty and `P` in check on ≥1 board → **checkmate**: `P` loses; each
|
||||
opponent on a board where `P` is in check is a **winner**; the remaining player(s)
|
||||
draw.
|
||||
- `synced` empty and `P` not in check → **stalemate**: game ends, all four draw
|
||||
(provisional — see §6).
|
||||
- **Global threefold repetition**: the combined key (four boards' piece placement +
|
||||
castling rights + en-passant squares, plus `currentPlayer`) has occurred three
|
||||
times → game ends, all draw.
|
||||
- **Global 50-move rule**: 50 full rounds with no capture and no pawn move on any
|
||||
board → game ends, all draw.
|
||||
|
||||
The result is a per-player map of `'win' | 'draw' | 'loss'`. The game ends at the
|
||||
first terminal event.
|
||||
|
||||
### 4.7 Save / load (`notation.ts`)
|
||||
|
||||
```json
|
||||
{ "variant": "duplicate-chess", "version": 1,
|
||||
"moves": [ { "player": "N", "from": "e2", "to": "e4" }, ... ] }
|
||||
```
|
||||
|
||||
Save = serialize `history` and trigger a file download. Load = parse and `replayTo`
|
||||
the full list. The move list is sufficient to reconstruct everything.
|
||||
|
||||
---
|
||||
|
||||
## 5. The UI
|
||||
|
||||
### 5.1 The compass
|
||||
|
||||
Confirmed against the inventor's sketch (`blind_chess/USERFILES/4personchess.png`).
|
||||
|
||||
- Four boards rendered as **45° diamonds** in an X / pinwheel.
|
||||
- Per-board rotation: **NW 225°, NE 135°, SW 315°, SE 45°**. Each rotation puts that
|
||||
board's White player's home rank on the edge facing their seat, oriented to read
|
||||
right-way-up from that seat (standard chess: your pieces near you, glyphs pointing
|
||||
away into the board).
|
||||
- Pieces rotate **with** their board — so each player's army faces their seat.
|
||||
- Players sit in the four V-notches between the diamonds: North top, East right,
|
||||
South bottom, West left.
|
||||
- The on-move player's two boards carry a coloured **turn-glow**.
|
||||
|
||||
### 5.2 Player colours
|
||||
|
||||
Four distinct piece colours, one per player (one suggested palette: North blue,
|
||||
South red, East violet, West orange — final palette is an implementation detail).
|
||||
Recolouring rather than White/Black fill makes two-board ownership instantly
|
||||
readable: North's army is the same colour on both NW and NE. Pieces carry a dark
|
||||
outline so they stay legible on both square shades. Pieces may be Unicode glyphs for
|
||||
v1; a tintable SVG set is a possible upgrade.
|
||||
|
||||
### 5.3 Intersection highlighting (teaching mode)
|
||||
|
||||
When the operator clicks (grabs) a piece belonging to the current player, both that
|
||||
player's boards highlight:
|
||||
|
||||
- **Green dot** — a *playable* destination (legal on both boards; in `synced`).
|
||||
- **Grey dashed dot** — legal on *that board only*; the coupling forbids it.
|
||||
- **Cyan outline** — the grabbed square.
|
||||
|
||||
This makes the divergence between a player's two boards directly visible, and is the
|
||||
reason the project exists. Clicking a destination plays the move; clicking elsewhere
|
||||
or the grabbed piece again cancels.
|
||||
|
||||
### 5.4 Ghosts
|
||||
|
||||
Rendered in place at reduced opacity with a dashed ring in the owning player's
|
||||
colour.
|
||||
|
||||
### 5.5 Side panel
|
||||
|
||||
- **Turn indicator** — whose move, ghost counts, check status.
|
||||
- **Move log** — coordinate notation, one row per round, four columns (N/S/E/W),
|
||||
one identical token per player (`e2e4`). SAN is not used: its disambiguation
|
||||
differs between a player's two boards once they diverge, so it cannot be the
|
||||
single identical token.
|
||||
- **Legend** — highlight meanings, ghost marker, the four player colours.
|
||||
- **Controls** — New game, Undo, Prev/Next (history scrubbing), Save, Load.
|
||||
|
||||
### 5.6 Move input and promotion
|
||||
|
||||
- **Click-to-move**: click a piece → triple-highlight appears → click a destination.
|
||||
Click-to-move (not drag) is cleaner on rotated boards.
|
||||
- **Promotion**: when the chosen move is a pawn reaching the last rank, a dialog
|
||||
picks the piece; both pawns promote identically.
|
||||
|
||||
---
|
||||
|
||||
## 6. Provisional endgame rules
|
||||
|
||||
The inventor conversation fully specifies the common ending (first checkmate → one
|
||||
winner, one loser, two draws) but leaves edge cases open. The operator chose to ship
|
||||
**provisional defaults now**, clearly marked, for Andrew to revise later. These are
|
||||
**PROVISIONAL — Claude's defaults, not Andrew's rulings**:
|
||||
|
||||
| Case | Provisional ruling |
|
||||
|------|--------------------|
|
||||
| **Single-player stalemate** (no synchronized move, not in check) | The whole game ends; all four players draw. No frozen-board continuation — this keeps the engine free of multi-player-elimination logic and matches "it is possible for everyone to draw." |
|
||||
| **Double-board checkmate** (mated while in check on both boards, by both opponents) | Both checking opponents are recorded as winners; the mated player loses; the fourth player draws. Generalizes "the winner is the one who checkmates" without a tiebreak. |
|
||||
| **Threefold repetition / 50-move** | Evaluated on the whole four-board system (all four positions + side-to-move), not per board. Triggers an all-draw game end. |
|
||||
| **Insufficient material** | Not auto-detected (rare and hard to define across four coupled boards). The operator may declare a draw manually. |
|
||||
|
||||
Each provisional rule must be marked in code (a `PROVISIONAL` comment or constant)
|
||||
so a future ruling from Andrew can be located and applied cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 7. Scope
|
||||
|
||||
**In scope (v1):**
|
||||
|
||||
- Play a full game from the standard start, operator-driving all four players.
|
||||
- The compass UI with pinwheel boards and four player colours.
|
||||
- Intersection (teaching-mode) highlighting.
|
||||
- Ghost detection and rendering.
|
||||
- Checkmate / stalemate / draw detection with the provisional rules.
|
||||
- Coordinate-notation move log.
|
||||
- Undo and history scrubbing.
|
||||
- Save / load a game to a JSON file.
|
||||
|
||||
**Out of scope (v1):**
|
||||
|
||||
- Networked four-player play (separable later project; would reuse `src/engine/`).
|
||||
- AI opponents (the sandbox is operator-driven).
|
||||
- A free position editor (play-from-start keeps every shown position reachable by
|
||||
legal play, preserving the "every board is real chess" invariant).
|
||||
- Insufficient-material auto-detection.
|
||||
- Deployment behind Caddy (the static build can be hosted later trivially).
|
||||
- Mobile-specific polish (four boards want a wide screen; desktop-first).
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- `src/engine/` is pure TypeScript with no DOM — covered by **vitest**:
|
||||
- synchronized-move intersection (including divergence, castling, en passant);
|
||||
- ghost derivation (lifecycle: lockstep → ghost → gone);
|
||||
- endgame detection (single- and double-board checkmate, stalemate, threefold,
|
||||
50-move);
|
||||
- history replay / undo / scrub correctness;
|
||||
- a scripted full game played to a terminal state.
|
||||
- Svelte components: `svelte-check` plus manual browser testing — same division of
|
||||
labour as `blind_chess` (no component test harness by design).
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions / future
|
||||
|
||||
- **The provisional rules in §6** need Andrew's confirmation; the
|
||||
double-board-mate winner rule and the stalemate-ends-the-game rule are the two
|
||||
most consequential.
|
||||
- **50-move counting units** (rounds vs plies) — to be pinned during implementation;
|
||||
provisionally "50 rounds."
|
||||
- **Rotated-board ergonomics** — playing on a board rotated 135–225° is harder to
|
||||
read than an upright board. v1 ships the static pinwheel as drawn. If play proves
|
||||
awkward, a candidate enhancement is a click-to-focus that temporarily uprights the
|
||||
active player's two boards. Not in v1 scope.
|
||||
- **Networked four-player play** is the natural follow-on project and the reason the
|
||||
engine is kept DOM-free and self-contained.
|
||||
|
||||
---
|
||||
|
||||
## 10. Source material
|
||||
|
||||
- `blind_chess/USERFILES/4-person-chess.txt` — the original inventor conversation
|
||||
defining the variant.
|
||||
- `blind_chess/USERFILES/4personchess.png` — Andrew's sketch of the compass layout.
|
||||
- Brainstorming mockups: `blind_chess/.superpowers/brainstorm/.../content/`
|
||||
(`layout-v6.html` is the approved compass layout).
|
||||
Reference in New Issue
Block a user