Files
duplicate_chess/docs/superpowers/specs/2026-05-19-duplicate-chess-design.md
T
claude (duplicate_chess) 9611c0ae0e 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>
2026-05-19 00:08:50 -04:00

16 KiB
Raw Blame History

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.
  • Checkmatesynced 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)

{ "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 135225° 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).