14-task TDD plan: project scaffold, the seven-module engine (vitest-covered), and five Svelte components. Covers every section of the design spec. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
57 KiB
Duplicate Chess Sandbox — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a local, single-operator browser sandbox for "duplicate chess" — a four-player coupled-board chess variant — that enforces the synchronized-move rule, renders ghosts, shows the move-legality intersection, and detects the endgame.
Architecture: A single Vite + Svelte 5 + TypeScript app, no server. A pure, DOM-free engine (src/engine/) holds four chess.js games and derives a player's legal moves as the intersection of the moves legal on their two boards. The Svelte UI (src/lib/) renders the four boards in a pinwheel "compass" and is driven entirely by the engine.
Tech Stack: Vite, Svelte 5 (runes), TypeScript, chess.js (per-board orthodox chess), vitest (engine tests).
Full design: docs/superpowers/specs/2026-05-19-duplicate-chess-design.md.
File structure
duplicate_chess/
package.json, tsconfig.json, vite.config.ts, svelte.config.js, index.html
src/
engine/
types.ts shared engine types (no runtime code)
boards.ts board/player/turn-order constant maps
game.ts DuplicateGame: 4 chess.js, history, move application, counters
legality.ts legalSyncedMoves + selectionHighlight (the intersection)
ghosts.ts ghost derivation
endgame.ts checkmate / stalemate / threefold / fifty-move detection
notation.ts coordinate notation + save/load JSON
test-helpers.ts playSymmetric() shared by tests
*.test.ts co-located vitest specs
lib/
stores/game.svelte.ts reactive store wrapping the engine
Board.svelte one rotatable board (pieces, highlights, ghosts, clicks)
Compass.svelte the four-board pinwheel + player labels
Panel.svelte turn indicator, move log, legend, controls
PromotionDialog.svelte promotion piece picker
App.svelte
app.css global styles + CSS variables
main.ts
Dependency direction (no cycles): legality/ghosts/endgame/notation → game → boards → types.
Task 1: Project scaffold
Files:
-
Create:
package.json,vite.config.ts,tsconfig.json,svelte.config.js,index.html,src/main.ts,src/App.svelte,src/app.css,src/vite-env.d.ts -
Step 1: Scaffold the Vite + Svelte + TS project
Run from /home/claude/bin/duplicate_chess:
pnpm create vite@latest . --template svelte-ts
pnpm add chess.js
pnpm add -D vitest
If pnpm create vite refuses because the directory is not empty, scaffold into a temp subdir and move files:
pnpm create vite@latest .vite-tmp --template svelte-ts
cp -r .vite-tmp/. . && rm -rf .vite-tmp
pnpm add chess.js && pnpm add -D vitest
- Step 2: Add a vitest script and config
Edit package.json — add to "scripts": "test": "vitest run", "test:watch": "vitest".
Create vitest.config.ts:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: { environment: 'node', include: ['src/**/*.test.ts'] },
});
- Step 3: Replace the scaffold's demo content with a placeholder
Replace src/App.svelte entirely:
<main>
<h1>Duplicate Chess</h1>
<p>Sandbox under construction.</p>
</main>
Replace src/app.css with an empty file for now (real styles arrive in Task 14). Ensure src/main.ts imports ./app.css and mounts App (the scaffold already does this — leave it).
- Step 4: Verify build, typecheck, and test all run clean
pnpm run build
pnpm exec svelte-check --tsconfig ./tsconfig.json
pnpm test
Expected: build succeeds; svelte-check reports 0 errors; vitest reports "No test files found" (exit 0) — that is fine, tests arrive next.
- Step 5: Commit
git add -A
git commit -m "chore: scaffold Vite + Svelte 5 + TS project with chess.js and vitest"
Task 2: Engine types and constants
Files:
-
Create:
src/engine/types.ts,src/engine/boards.ts -
Test:
src/engine/boards.test.ts -
Step 1: Write
src/engine/types.ts(pure types — no test needed)
export type BoardId = 'NW' | 'NE' | 'SW' | 'SE';
export type Player = 'N' | 'S' | 'E' | 'W';
export type Color = 'w' | 'b';
export type Square = string;
export type PromotionPiece = 'q' | 'r' | 'b' | 'n';
export interface SyncMove {
from: Square;
to: Square;
promotion?: PromotionPiece;
}
export interface HistoryEntry extends SyncMove {
player: Player;
}
export interface GhostMarker {
board: BoardId;
square: Square;
}
export type PlayerResult = 'win' | 'draw' | 'loss';
export type GameResult = Record<Player, PlayerResult>;
export type GameState = 'playing' | 'checkmate' | 'stalemate' | 'draw';
export type DrawReason = 'stalemate' | 'threefold' | 'fifty-move' | 'manual';
export interface GameStatus {
state: GameState;
/** Present when state !== 'playing'. */
result?: GameResult;
/** Present for a draw/stalemate. */
reason?: DrawReason;
/** Boards on which the player to move is currently in check. */
checks: BoardId[];
}
- Step 2: Write the failing test
src/engine/boards.test.ts
import { describe, it, expect } from 'vitest';
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS, PLAYER_COLOR, BOARD_PLAYERS, BOARD_ROTATION } from './boards';
describe('boards constants', () => {
it('lists four boards and four players in turn order', () => {
expect(BOARD_IDS).toEqual(['NW', 'NE', 'SW', 'SE']);
expect(PLAYERS).toEqual(['N', 'S', 'E', 'W']);
});
it('each player controls exactly two boards', () => {
for (const p of PLAYERS) expect(PLAYER_BOARDS[p]).toHaveLength(2);
expect(PLAYER_BOARDS.N).toEqual(['NW', 'NE']);
expect(PLAYER_BOARDS.W).toEqual(['NW', 'SW']);
});
it('board players are consistent with player boards', () => {
for (const b of BOARD_IDS) {
const { w, b: black } = BOARD_PLAYERS[b];
expect(PLAYER_BOARDS[w]).toContain(b);
expect(PLAYER_BOARDS[black]).toContain(b);
expect(PLAYER_COLOR[w]).toBe('w');
expect(PLAYER_COLOR[black]).toBe('b');
}
});
it('has a rotation for every board', () => {
expect(BOARD_ROTATION).toEqual({ NW: 225, NE: 135, SW: 315, SE: 45 });
});
});
- Step 3: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — boards.ts does not exist.
- Step 4: Write
src/engine/boards.ts
import type { BoardId, Player, Color } from './types';
export const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
/** Turn order. */
export const PLAYERS: Player[] = ['N', 'S', 'E', 'W'];
/** The two boards each player controls (order is stable: [boardA, boardB]). */
export const PLAYER_BOARDS: Record<Player, [BoardId, BoardId]> = {
N: ['NW', 'NE'],
S: ['SW', 'SE'],
E: ['NE', 'SE'],
W: ['NW', 'SW'],
};
/** The colour each player plays on both their boards. */
export const PLAYER_COLOR: Record<Player, Color> = {
N: 'w', S: 'w', E: 'b', W: 'b',
};
/** The white and black player of each board. */
export const BOARD_PLAYERS: Record<BoardId, { w: Player; b: Player }> = {
NW: { w: 'N', b: 'W' },
NE: { w: 'N', b: 'E' },
SW: { w: 'S', b: 'W' },
SE: { w: 'S', b: 'E' },
};
/** Compass rotation in degrees for rendering each board (see spec §5.1). */
export const BOARD_ROTATION: Record<BoardId, number> = {
NW: 225, NE: 135, SW: 315, SE: 45,
};
- Step 5: Run the test to verify it passes, then commit
Run: pnpm test
Expected: PASS (4 tests).
git add src/engine/types.ts src/engine/boards.ts src/engine/boards.test.ts
git commit -m "feat(engine): board/player constant maps and shared types"
Task 3: DuplicateGame core
Files:
- Create:
src/engine/game.ts,src/engine/test-helpers.ts - Test:
src/engine/game.test.ts
DuplicateGame holds four chess.js games, the move history, and the two
draw-clock counters. It is the single owner of game state.
- Step 1: Write
src/engine/test-helpers.ts(shared by later tests)
import type { DuplicateGame } from './game';
import type { SyncMove } from './types';
/**
* Apply a list of [whiteMove, blackMove?] underlying ply-pairs symmetrically:
* North then South play the white move, East then West play the black move.
* While every move is symmetric all four boards stay identical, so each board
* behaves as an ordinary chess game — useful for reaching ordinary checkmate /
* stalemate / repetition positions. Omit blackMove for a final unanswered white move.
*/
export function playSymmetric(
game: DuplicateGame,
pairs: Array<[SyncMove, SyncMove?]>,
): void {
for (const [white, black] of pairs) {
game.applyMove(white); // N
game.applyMove(white); // S
if (black) {
game.applyMove(black); // E
game.applyMove(black); // W
}
}
}
- Step 2: Write the failing test
src/engine/game.test.ts
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
const START = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR';
function placement(fen: string) {
return fen.split(' ')[0];
}
describe('DuplicateGame', () => {
it('starts with four boards in the standard position, North to move', () => {
const g = new DuplicateGame();
expect(g.ply).toBe(0);
expect(g.currentPlayer).toBe('N');
for (const id of ['NW', 'NE', 'SW', 'SE'] as const) {
expect(placement(g.boards[id].fen())).toBe(START);
}
});
it('applies a synchronized move to the current player\'s two boards only', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // North
expect(g.ply).toBe(1);
expect(g.currentPlayer).toBe('S');
expect(placement(g.boards.NW.fen())).toContain('4P3'); // pawn advanced
expect(placement(g.boards.NE.fen())).toContain('4P3');
expect(placement(g.boards.SW.fen())).toBe(START); // untouched
expect(placement(g.boards.SE.fen())).toBe(START);
});
it('cycles the current player N -> S -> E -> W -> N', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // N
g.applyMove({ from: 'e2', to: 'e4' }); // S
g.applyMove({ from: 'e7', to: 'e5' }); // E
g.applyMove({ from: 'e7', to: 'e5' }); // W
expect(g.currentPlayer).toBe('N');
expect(g.ply).toBe(4);
});
it('throws on a move not legal on both of the player\'s boards', () => {
const g = new DuplicateGame();
expect(() => g.applyMove({ from: 'e2', to: 'e5' })).toThrow();
});
it('undo removes the last move and restores the position', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' });
g.undo();
expect(g.ply).toBe(0);
expect(g.currentPlayer).toBe('N');
expect(placement(g.boards.NW.fen())).toBe(START);
});
it('rebuilds from a history array passed to the constructor', () => {
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
]);
expect(g.ply).toBe(2);
expect(g.currentPlayer).toBe('E');
});
it('resets the progress clock on a pawn move or capture', () => {
const g = new DuplicateGame();
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move -> clock stays 0
expect(g.pliesSinceProgress).toBe(0);
g.applyMove({ from: 'e2', to: 'e4' }); // pawn move
expect(g.pliesSinceProgress).toBe(0);
g.applyMove({ from: 'g8', to: 'f6' }); // knight move -> clock increments
expect(g.pliesSinceProgress).toBe(1);
});
});
- Step 3: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — game.ts does not exist.
- Step 4: Write
src/engine/game.ts
import { Chess } from 'chess.js';
import type { BoardId, Player, SyncMove, HistoryEntry } from './types';
import { BOARD_IDS, PLAYERS, PLAYER_BOARDS } from './boards';
/** A chess.js move result has these fields we rely on. */
interface ChessMoveResult {
piece: string;
captured?: string;
}
export class DuplicateGame {
readonly boards: Record<BoardId, Chess>;
readonly history: HistoryEntry[] = [];
/** Plies since the last capture or pawn move on any board (for the 50-move rule). */
pliesSinceProgress = 0;
/** Repetition keys of the whole 4-board system, one per position incl. the start. */
readonly repetitionKeys: string[] = [];
constructor(history: HistoryEntry[] = []) {
this.boards = {
NW: new Chess(), NE: new Chess(), SW: new Chess(), SE: new Chess(),
};
this.repetitionKeys.push(this.systemKey());
for (const entry of history) this.applyMove(entry, entry.player);
}
get ply(): number {
return this.history.length;
}
get currentPlayer(): Player {
return PLAYERS[this.history.length % 4];
}
/** Whether `move` is a legal chess move on `board` in the current position. */
isLegalOnBoard(board: BoardId, move: SyncMove): boolean {
return this.boards[board].moves({ verbose: true }).some(
(m) =>
m.from === move.from &&
m.to === move.to &&
(m.promotion ?? undefined) === (move.promotion ?? undefined),
);
}
/** Apply one synchronized move to a player's two boards. Throws if illegal on either. */
applyMove(move: SyncMove, player: Player = this.currentPlayer): void {
const [a, b] = PLAYER_BOARDS[player];
if (!this.isLegalOnBoard(a, move) || !this.isLegalOnBoard(b, move)) {
throw new Error(
`Illegal synchronized move ${move.from}${move.to} for ${player}`,
);
}
const ra = this.boards[a].move(move) as unknown as ChessMoveResult;
const rb = this.boards[b].move(move) as unknown as ChessMoveResult;
this.history.push({ ...move, player });
const progress = isProgress(ra) || isProgress(rb);
this.pliesSinceProgress = progress ? 0 : this.pliesSinceProgress + 1;
this.repetitionKeys.push(this.systemKey());
}
/** Remove the last move by replaying the truncated history. */
undo(): void {
if (this.history.length === 0) return;
const replay = this.history.slice(0, -1);
for (const id of BOARD_IDS) this.boards[id].reset();
this.history.length = 0;
this.pliesSinceProgress = 0;
this.repetitionKeys.length = 0;
this.repetitionKeys.push(this.systemKey());
for (const e of replay) this.applyMove(e, e.player);
}
/** A repetition key for the whole system: each board's placement+castling+ep, plus the side to move. */
systemKey(): string {
return (
BOARD_IDS.map((id) => {
const parts = this.boards[id].fen().split(' ');
return `${parts[0]}${parts[2]}${parts[3]}`;
}).join('|') + ':' + this.currentPlayer
);
}
/** How many times the current system position has occurred. */
repetitionCount(): number {
const current = this.repetitionKeys[this.repetitionKeys.length - 1];
return this.repetitionKeys.filter((k) => k === current).length;
}
}
function isProgress(result: ChessMoveResult): boolean {
return result.piece === 'p' || result.captured != null;
}
- Step 5: Run the test to verify it passes, then commit
Run: pnpm test
Expected: PASS (game tests + boards tests).
git add src/engine/game.ts src/engine/test-helpers.ts src/engine/game.test.ts
git commit -m "feat(engine): DuplicateGame core — boards, history, undo, draw clocks"
Task 4: Synchronized-move legality
Files:
-
Create:
src/engine/legality.ts -
Test:
src/engine/legality.test.ts -
Step 1: Write the failing test
src/engine/legality.test.ts
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { legalSyncedMoves, selectionHighlight } from './legality';
describe('legalSyncedMoves', () => {
it('returns all 20 white opening moves when a player\'s two boards are identical', () => {
const g = new DuplicateGame();
expect(legalSyncedMoves(g)).toHaveLength(20);
});
it('excludes a move legal on only one of the player\'s boards', () => {
// N e2e4, S e2e4, E e7e5, W d7d5 -> NW black has pe5, NE black has pd5.
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'e7', to: 'e5' },
{ player: 'W', from: 'd7', to: 'd5' },
]);
expect(g.currentPlayer).toBe('N');
const keys = legalSyncedMoves(g).map((m) => `${m.from}${m.to}`);
// e4-d5 is a capture on NE but illegal on NW (d5 empty there): not synced.
expect(keys).not.toContain('e4d5');
// e4-e5 is legal on NE but blocked on NW (black pawn on e5): not synced.
expect(keys).not.toContain('e4e5');
});
});
describe('selectionHighlight', () => {
it('marks every destination playable when the two boards agree', () => {
const g = new DuplicateGame();
const h = selectionHighlight(g, 'e2');
expect(h.playable.sort()).toEqual(['e3', 'e4']);
expect(h.onlyA).toEqual([]);
expect(h.onlyB).toEqual([]);
});
it('splits destinations into playable vs board-local-only on divergence', () => {
const g = new DuplicateGame([
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
{ player: 'E', from: 'e7', to: 'e5' },
{ player: 'W', from: 'd7', to: 'd5' },
]);
const h = selectionHighlight(g, 'e4'); // North's e4 pawn
expect(h.boardA).toBe('NW');
expect(h.boardB).toBe('NE');
expect(h.playable).toEqual([]); // nothing legal on both
expect(h.onlyA).toEqual([]); // e4 is blocked on NW
expect(h.onlyB.sort()).toEqual(['d5', 'e5']); // capture + advance on NE only
});
});
- Step 2: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — legality.ts does not exist.
- Step 3: Write
src/engine/legality.ts
import type { DuplicateGame } from './game';
import type { SyncMove, Square, BoardId, PromotionPiece } from './types';
import { PLAYER_BOARDS } from './boards';
function key(m: { from: string; to: string; promotion?: string }): string {
return `${m.from}${m.to}${m.promotion ?? ''}`;
}
/** Every synchronized-legal move for the player to move (the intersection). */
export function legalSyncedMoves(game: DuplicateGame): SyncMove[] {
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
const movesA = game.boards[a].moves({ verbose: true });
const keysB = new Set(game.boards[b].moves({ verbose: true }).map(key));
const seen = new Set<string>();
const result: SyncMove[] = [];
for (const m of movesA) {
const k = key(m);
if (keysB.has(k) && !seen.has(k)) {
seen.add(k);
result.push({
from: m.from,
to: m.to,
promotion: (m.promotion as PromotionPiece) || undefined,
});
}
}
return result;
}
export interface SelectionHighlight {
/** The current player's first board. */
boardA: BoardId;
/** The current player's second board. */
boardB: BoardId;
/** Destinations legal on BOTH boards (actually playable). */
playable: Square[];
/** Destinations legal on board A only. */
onlyA: Square[];
/** Destinations legal on board B only. */
onlyB: Square[];
}
/** Triple-highlight data for the current player's piece grabbed at `from`. */
export function selectionHighlight(
game: DuplicateGame,
from: Square,
): SelectionHighlight {
const [a, b] = PLAYER_BOARDS[game.currentPlayer];
const destA = new Set(
game.boards[a].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
);
const destB = new Set(
game.boards[b].moves({ verbose: true }).filter((m) => m.from === from).map((m) => m.to),
);
const playable: Square[] = [];
const onlyA: Square[] = [];
const onlyB: Square[] = [];
for (const sq of destA) (destB.has(sq) ? playable : onlyA).push(sq);
for (const sq of destB) if (!destA.has(sq)) onlyB.push(sq);
return { boardA: a, boardB: b, playable, onlyA, onlyB };
}
- Step 4: Run the test to verify it passes
Run: pnpm test
Expected: PASS.
- Step 5: Commit
git add src/engine/legality.ts src/engine/legality.test.ts
git commit -m "feat(engine): synchronized-move intersection and selection highlight"
Task 5: Ghost derivation
Files:
-
Create:
src/engine/ghosts.ts -
Test:
src/engine/ghosts.test.ts -
Step 1: Write the failing test
src/engine/ghosts.test.ts
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { ghosts } from './ghosts';
describe('ghosts', () => {
it('reports no ghosts at the start', () => {
expect(ghosts(new DuplicateGame())).toEqual([]);
});
it('forms a ghost when a piece is captured on one board but not its twin', () => {
const g = new DuplicateGame();
// Symmetric opening so all four boards stay identical...
playSymmetric(g, [
[{ from: 'e2', to: 'e4' }, { from: 'e7', to: 'e5' }],
[{ from: 'g1', to: 'f3' }, { from: 'b8', to: 'c6' }],
]);
// ...then North & South each play Nxe5 (capturing the e5 pawn),
// and East captures that knight only on its boards (NE, SE).
g.applyMove({ from: 'f3', to: 'e5' }); // N: Nf3xe5 on NW, NE
g.applyMove({ from: 'f3', to: 'e5' }); // S: Nf3xe5 on SW, SE
g.applyMove({ from: 'c6', to: 'e5' }); // E: Nc6xe5 on NE, SE — captures the white knight
// North's knight on e5 survives on NW but was captured on NE -> NW/e5 is a ghost.
// South's knight on e5 survives on SW but was captured on SE -> SW/e5 is a ghost.
const result = ghosts(g).sort((x, y) => (x.board < y.board ? -1 : 1));
expect(result).toEqual([
{ board: 'NW', square: 'e5' },
{ board: 'SW', square: 'e5' },
]);
});
});
- Step 2: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — ghosts.ts does not exist.
- Step 3: Write
src/engine/ghosts.ts
import type { DuplicateGame } from './game';
import type { GhostMarker, BoardId, Color, Square } from './types';
import { PLAYERS, PLAYER_BOARDS, PLAYER_COLOR } from './boards';
/** Squares occupied by a piece of `color` on `board`. */
function colorSquares(game: DuplicateGame, board: BoardId, color: Color): Set<Square> {
const set = new Set<Square>();
for (const row of game.boards[board].board()) {
for (const cell of row) {
if (cell && cell.color === color) set.add(cell.square);
}
}
return set;
}
/**
* Ghosts across all four players. A player's non-ghost pieces always occupy
* identical squares on both their boards (they move in lockstep), so a piece is
* a ghost iff the player's other board has no same-colour piece on that square.
*/
export function ghosts(game: DuplicateGame): GhostMarker[] {
const markers: GhostMarker[] = [];
for (const player of PLAYERS) {
const [a, b] = PLAYER_BOARDS[player];
const color = PLAYER_COLOR[player];
const sqA = colorSquares(game, a, color);
const sqB = colorSquares(game, b, color);
for (const sq of sqA) if (!sqB.has(sq)) markers.push({ board: a, square: sq });
for (const sq of sqB) if (!sqA.has(sq)) markers.push({ board: b, square: sq });
}
return markers;
}
- Step 4: Run the test to verify it passes
Run: pnpm test
Expected: PASS.
- Step 5: Commit
git add src/engine/ghosts.ts src/engine/ghosts.test.ts
git commit -m "feat(engine): ghost derivation from cross-board piece comparison"
Task 6: Endgame detection
Files:
- Create:
src/engine/endgame.ts - Test:
src/engine/endgame.test.ts
Implements the provisional rules from spec §6.
- Step 1: Write the failing test
src/engine/endgame.test.ts
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { evaluateStatus } from './endgame';
describe('evaluateStatus', () => {
it('reports an ongoing game at the start', () => {
const s = evaluateStatus(new DuplicateGame());
expect(s.state).toBe('playing');
expect(s.checks).toEqual([]);
});
it('detects a double-board checkmate (Fool\'s mate, played symmetrically)', () => {
const g = new DuplicateGame();
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
expect(g.currentPlayer).toBe('N'); // North (White) is mated
const s = evaluateStatus(g);
expect(s.state).toBe('checkmate');
expect(s.checks.sort()).toEqual(['NE', 'NW']);
expect(s.result).toEqual({ N: 'loss', S: 'draw', E: 'win', W: 'win' });
});
it('detects threefold repetition of the whole system', () => {
const g = new DuplicateGame();
const cycle: Array<[{ from: string; to: string }, { from: string; to: string }]> = [
[{ from: 'g1', to: 'f3' }, { from: 'g8', to: 'f6' }],
[{ from: 'f3', to: 'g1' }, { from: 'f6', to: 'g8' }],
];
playSymmetric(g, cycle); // back to start (occurrence 2)
playSymmetric(g, cycle); // back to start (occurrence 3)
const s = evaluateStatus(g);
expect(s.state).toBe('draw');
expect(s.reason).toBe('threefold');
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
});
it('detects a stalemate as an all-draw game end (provisional rule)', () => {
const g = new DuplicateGame();
// The known fastest stalemate, played symmetrically on all four boards.
playSymmetric(g, [
[{ from: 'e2', to: 'e3' }, { from: 'a7', to: 'a5' }],
[{ from: 'd1', to: 'h5' }, { from: 'a8', to: 'a6' }],
[{ from: 'h5', to: 'a5' }, { from: 'h7', to: 'h5' }],
[{ from: 'a5', to: 'c7' }, { from: 'a6', to: 'h6' }],
[{ from: 'h2', to: 'h4' }, { from: 'f7', to: 'f6' }],
[{ from: 'c7', to: 'd7' }, { from: 'e8', to: 'f7' }],
[{ from: 'd7', to: 'b7' }, { from: 'd8', to: 'd3' }],
[{ from: 'b7', to: 'b8' }, { from: 'd3', to: 'h7' }],
[{ from: 'b8', to: 'c8' }, { from: 'f7', to: 'g6' }],
[{ from: 'c8', to: 'e6' }], // no black reply — Black is stalemated
]);
expect(g.currentPlayer).toBe('E'); // a Black player, with no move
const s = evaluateStatus(g);
expect(s.state).toBe('stalemate');
expect(s.reason).toBe('stalemate');
expect(s.result).toEqual({ N: 'draw', S: 'draw', E: 'draw', W: 'draw' });
});
});
- Step 2: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — endgame.ts does not exist.
- Step 3: Write
src/engine/endgame.ts
import type { DuplicateGame } from './game';
import type { GameStatus, GameResult, BoardId } from './types';
import { PLAYERS, PLAYER_BOARDS, BOARD_PLAYERS } from './boards';
import { legalSyncedMoves } from './legality';
/** PROVISIONAL (spec §6): the 50-move rule fires after this many rounds. */
const FIFTY_MOVE_ROUNDS = 50;
const FIFTY_MOVE_PLIES = FIFTY_MOVE_ROUNDS * 4;
function allDraw(): GameResult {
return { N: 'draw', S: 'draw', E: 'draw', W: 'draw' };
}
/** Evaluate the game from the perspective of the player to move. */
export function evaluateStatus(game: DuplicateGame): GameStatus {
const player = game.currentPlayer;
const [a, b] = PLAYER_BOARDS[player];
const checks: BoardId[] = [];
if (game.boards[a].inCheck()) checks.push(a);
if (game.boards[b].inCheck()) checks.push(b);
const synced = legalSyncedMoves(game);
if (synced.length === 0) {
if (checks.length > 0) {
// Checkmate. PROVISIONAL (spec §6): every opponent delivering a check wins.
const winners = checks.map((board) =>
BOARD_PLAYERS[board].w === player
? BOARD_PLAYERS[board].b
: BOARD_PLAYERS[board].w,
);
const result = {} as GameResult;
for (const p of PLAYERS) {
result[p] = p === player ? 'loss' : winners.includes(p) ? 'win' : 'draw';
}
return { state: 'checkmate', result, checks };
}
// PROVISIONAL (spec §6): a no-synchronized-move stalemate ends the game, all draw.
return { state: 'stalemate', result: allDraw(), reason: 'stalemate', checks };
}
if (game.repetitionCount() >= 3) {
return { state: 'draw', result: allDraw(), reason: 'threefold', checks };
}
if (game.pliesSinceProgress >= FIFTY_MOVE_PLIES) {
return { state: 'draw', result: allDraw(), reason: 'fifty-move', checks };
}
return { state: 'playing', checks };
}
- Step 4: Run the test to verify it passes
Run: pnpm test
Expected: PASS. If the stalemate test fails, re-verify the move sequence is the
canonical fastest stalemate (10 White moves, 9 Black moves) before changing code.
- Step 5: Commit
git add src/engine/endgame.ts src/engine/endgame.test.ts
git commit -m "feat(engine): endgame detection with provisional rules"
Task 7: Coordinate notation and save/load
Files:
-
Create:
src/engine/notation.ts -
Test:
src/engine/notation.test.ts -
Step 1: Write the failing test
src/engine/notation.test.ts
import { describe, it, expect } from 'vitest';
import { toCoordinate, serialize, deserialize } from './notation';
import type { HistoryEntry } from './types';
describe('notation', () => {
it('renders a move as a coordinate token', () => {
expect(toCoordinate({ from: 'e2', to: 'e4' })).toBe('e2e4');
expect(toCoordinate({ from: 'e7', to: 'e8', promotion: 'q' })).toBe('e7e8q');
});
it('round-trips a game through serialize/deserialize', () => {
const history: HistoryEntry[] = [
{ player: 'N', from: 'e2', to: 'e4' },
{ player: 'S', from: 'e2', to: 'e4' },
];
const restored = deserialize(serialize(history));
expect(restored).toEqual(history);
});
it('rejects a file that is not a duplicate-chess save', () => {
expect(() => deserialize('{"variant":"chess","version":1,"moves":[]}')).toThrow();
});
});
- Step 2: Run the test to verify it fails
Run: pnpm test
Expected: FAIL — notation.ts does not exist.
- Step 3: Write
src/engine/notation.ts
import type { SyncMove, HistoryEntry } from './types';
/** Coordinate notation, e.g. "e2e4" or "e7e8q". */
export function toCoordinate(move: SyncMove): string {
return `${move.from}${move.to}${move.promotion ?? ''}`;
}
export interface SavedGame {
variant: 'duplicate-chess';
version: 1;
moves: HistoryEntry[];
}
export function serialize(history: HistoryEntry[]): string {
const data: SavedGame = { variant: 'duplicate-chess', version: 1, moves: history };
return JSON.stringify(data, null, 2);
}
export function deserialize(json: string): HistoryEntry[] {
const data = JSON.parse(json) as Partial<SavedGame>;
if (data.variant !== 'duplicate-chess' || !Array.isArray(data.moves)) {
throw new Error('Not a duplicate-chess save file');
}
return data.moves;
}
- Step 4: Run the test to verify it passes
Run: pnpm test
Expected: PASS.
- Step 5: Commit
git add src/engine/notation.ts src/engine/notation.test.ts
git commit -m "feat(engine): coordinate notation and JSON save/load"
Task 8: Engine integration test — a scripted full game
Files:
-
Test:
src/engine/integration.test.ts -
Step 1: Write the integration test
src/engine/integration.test.ts
import { describe, it, expect } from 'vitest';
import { DuplicateGame } from './game';
import { playSymmetric } from './test-helpers';
import { legalSyncedMoves } from './legality';
import { ghosts } from './ghosts';
import { evaluateStatus } from './endgame';
import { serialize, deserialize } from './notation';
describe('integration: a scripted game played to checkmate', () => {
it('plays Fool\'s mate, stays consistent throughout, and ends correctly', () => {
const g = new DuplicateGame();
// The game is live and ongoing until the mate.
expect(evaluateStatus(g).state).toBe('playing');
expect(legalSyncedMoves(g).length).toBeGreaterThan(0);
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
// No captures occurred, so there are no ghosts.
expect(ghosts(g)).toEqual([]);
// North is checkmated.
const status = evaluateStatus(g);
expect(status.state).toBe('checkmate');
expect(status.result?.N).toBe('loss');
expect(legalSyncedMoves(g)).toEqual([]);
// The game round-trips through save/load and reproduces the same outcome.
const restored = new DuplicateGame(deserialize(serialize(g.history)));
expect(evaluateStatus(restored).state).toBe('checkmate');
expect(restored.history).toEqual(g.history);
});
it('undo from the mated position restores a playable game', () => {
const g = new DuplicateGame();
playSymmetric(g, [
[{ from: 'f2', to: 'f3' }, { from: 'e7', to: 'e5' }],
[{ from: 'g2', to: 'g4' }, { from: 'd8', to: 'h4' }],
]);
g.undo(); // take back W's d8h4
expect(evaluateStatus(g).state).toBe('playing');
expect(g.currentPlayer).toBe('W');
});
});
- Step 2: Run the test to verify it passes
Run: pnpm test
Expected: PASS — all engine suites green. No new implementation needed; this test
exercises the finished engine. If it fails, the failure points to a real engine
bug — fix the implicated module and re-run.
- Step 3: Commit
git add src/engine/integration.test.ts
git commit -m "test(engine): scripted full-game integration test"
Task 9: Reactive game store
Files:
- Create:
src/lib/stores/game.svelte.ts
The store keeps the DuplicateGame as a plain (non-reactive) field — chess.js
objects do not belong inside a Svelte proxy — and exposes a plain-data view
snapshot in a $state rune. Components read store.view.
- Step 1: Write
src/lib/stores/game.svelte.ts
import { DuplicateGame } from '../../engine/game';
import { legalSyncedMoves, selectionHighlight, type SelectionHighlight } from '../../engine/legality';
import { ghosts } from '../../engine/ghosts';
import { evaluateStatus } from '../../engine/endgame';
import { serialize, deserialize } from '../../engine/notation';
import { PLAYER_BOARDS } from '../../engine/boards';
import type {
BoardId, Player, Square, SyncMove, HistoryEntry, GhostMarker, GameStatus,
} from '../../engine/types';
/** A plain, reactivity-friendly snapshot of everything the UI renders. */
export interface GameView {
/** Piece-placement FEN field per board. */
fen: Record<BoardId, string>;
currentPlayer: Player;
ply: number;
ghosts: GhostMarker[];
status: GameStatus;
history: HistoryEntry[];
}
function buildView(game: DuplicateGame): GameView {
return {
fen: {
NW: game.boards.NW.fen(),
NE: game.boards.NE.fen(),
SW: game.boards.SW.fen(),
SE: game.boards.SE.fen(),
},
currentPlayer: game.currentPlayer,
ply: game.ply,
ghosts: ghosts(game),
status: evaluateStatus(game),
history: [...game.history],
};
}
class GameStore {
/** The authoritative live game — deliberately NOT a $state proxy. */
#game = new DuplicateGame();
/** Snapshot the UI renders. While scrubbing it reflects a past ply. */
view = $state<GameView>(buildView(this.#game));
/** The grabbed square, or null. */
selected = $state<Square | null>(null);
/** Triple-highlight for the grabbed piece, or null. */
highlight = $state<SelectionHighlight | null>(null);
/** A pawn move awaiting a promotion choice, or null. */
pendingPromotion = $state<{ from: Square; to: Square } | null>(null);
/** Ply currently being viewed; null means the live position. */
scrubPly = $state<number | null>(null);
get isScrubbing(): boolean {
return this.scrubPly !== null;
}
/** Which boards belong to the player to move (for the turn glow). */
get activeBoards(): [BoardId, BoardId] {
return PLAYER_BOARDS[this.#game.currentPlayer];
}
/** Grab a piece: must be the current player's turn and a live (non-scrub) view. */
select(square: Square): void {
if (this.isScrubbing) return;
if (this.selected === square) { this.clearSelection(); return; }
this.selected = square;
this.highlight = selectionHighlight(this.#game, square);
}
clearSelection(): void {
this.selected = null;
this.highlight = null;
}
/** Attempt to play the grabbed piece to `to`. Opens the promotion dialog if needed. */
commitTo(to: Square): void {
const from = this.selected;
if (from === null || this.highlight === null) return;
if (!this.highlight.playable.includes(to)) return; // not a synchronized-legal square
const moves = legalSyncedMoves(this.#game).filter((m) => m.from === from && m.to === to);
if (moves.length === 0) return;
if (moves.some((m) => m.promotion)) {
this.pendingPromotion = { from, to };
return;
}
this.#apply(moves[0]);
}
/** Finish a promotion started by commitTo. */
choosePromotion(piece: SyncMove['promotion']): void {
if (this.pendingPromotion === null) return;
this.#apply({ ...this.pendingPromotion, promotion: piece });
this.pendingPromotion = null;
}
cancelPromotion(): void {
this.pendingPromotion = null;
}
#apply(move: SyncMove): void {
this.#game.applyMove(move);
this.clearSelection();
this.scrubPly = null;
this.view = buildView(this.#game);
}
undo(): void {
this.#game.undo();
this.clearSelection();
this.scrubPly = null;
this.view = buildView(this.#game);
}
newGame(): void {
this.#game = new DuplicateGame();
this.clearSelection();
this.pendingPromotion = null;
this.scrubPly = null;
this.view = buildView(this.#game);
}
/** Scrub the move history; null returns to the live position. */
scrubTo(ply: number | null): void {
this.clearSelection();
if (ply === null || ply >= this.#game.ply) {
this.scrubPly = null;
this.view = buildView(this.#game);
return;
}
this.scrubPly = ply;
this.view = buildView(new DuplicateGame(this.#game.history.slice(0, ply)));
}
/** Manually declare a draw (provisional: insufficient material is not auto-detected). */
declareDraw(): void {
this.view = {
...this.view,
status: { state: 'draw', reason: 'manual', checks: [],
result: { N: 'draw', S: 'draw', E: 'draw', W: 'draw' } },
};
}
save(): void {
const blob = new Blob([serialize(this.#game.history)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `duplicate-chess-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
async load(file: File): Promise<void> {
const history = deserialize(await file.text());
this.#game = new DuplicateGame(history);
this.clearSelection();
this.pendingPromotion = null;
this.scrubPly = null;
this.view = buildView(this.#game);
}
}
export const gameStore = new GameStore();
- Step 2: Typecheck
Run: pnpm exec svelte-check --tsconfig ./tsconfig.json
Expected: 0 errors. (Runes in a .svelte.ts file are valid; Vite's Svelte plugin compiles them.)
- Step 3: Commit
git add src/lib/stores/game.svelte.ts
git commit -m "feat(ui): reactive game store wrapping the engine"
Task 10: Board component
Files:
- Create:
src/lib/Board.svelte
Renders one board: an 8×8 grid, pieces coloured per owning player, the board rotated per the compass, ghost markers, and the triple-highlight. Click-to-move.
- Step 1: Write
src/lib/Board.svelte
<script lang="ts">
import type { BoardId, Player, Square } from '../engine/types';
import type { SelectionHighlight } from '../engine/legality';
import { BOARD_ROTATION, BOARD_PLAYERS } from '../engine/boards';
interface Props {
id: BoardId;
fen: string;
/** Player colours, e.g. { N:'#4a90d9', ... }. */
colors: Record<Player, string>;
ghosts: Square[];
/** Highlight for this board, or null if no piece is grabbed / not active. */
highlight: { playable: Square[]; local: Square[]; selected: Square | null } | null;
active: boolean;
onSquare: (square: Square) => void;
}
let { id, fen, colors, ghosts, highlight, active, onSquare }: Props = $props();
const FILES = 'abcdefgh';
const GLYPH: Record<string, string> = {
k: '♚', q: '♛', r: '♜', b: '♝', n: '♞', p: '♟',
};
interface Cell { square: Square; piece: { glyph: string; color: string } | null; }
let cells = $derived.by<Cell[]>(() => {
const placement = fen.split(' ')[0];
const white = colors[BOARD_PLAYERS[id].w];
const black = colors[BOARD_PLAYERS[id].b];
const map: Record<string, { glyph: string; color: string }> = {};
placement.split('/').forEach((row, ri) => {
const rank = 8 - ri;
let file = 0;
for (const ch of row) {
if (/\d/.test(ch)) { file += Number(ch); continue; }
const isWhite = ch === ch.toUpperCase();
map[`${FILES[file]}${rank}`] = {
glyph: GLYPH[ch.toLowerCase()],
color: isWhite ? white : black,
};
file += 1;
}
});
const out: Cell[] = [];
for (let rank = 8; rank >= 1; rank--) {
for (let f = 0; f < 8; f++) {
const square = `${FILES[f]}${rank}`;
out.push({ square, piece: map[square] ?? null });
}
}
return out;
});
function classes(cell: Cell, index: number): string {
const dark = (index + Math.floor(index / 8)) % 2 === 1;
const hl = highlight;
const list = ['sq', dark ? 'dark' : 'light'];
if (ghosts.includes(cell.square)) list.push('ghost-sq');
if (hl?.playable.includes(cell.square)) list.push(cell.piece ? 'play occ' : 'play');
if (hl?.local.includes(cell.square)) list.push('local');
if (hl?.selected === cell.square) list.push('selected');
return list.join(' ');
}
</script>
<div class="board" class:active style="--rot:{BOARD_ROTATION[id]}deg">
{#each cells as cell, i (cell.square)}
<button class={classes(cell, i)} onclick={() => onSquare(cell.square)} aria-label={cell.square}>
{#if cell.piece}
<span class="pc" class:ghost={ghosts.includes(cell.square)}
style="color:{cell.piece.color}">{cell.piece.glyph}</span>
{/if}
</button>
{/each}
</div>
<style>
.board {
display: grid;
grid-template-columns: repeat(8, var(--sq, 34px));
grid-template-rows: repeat(8, var(--sq, 34px));
transform: rotate(var(--rot));
border: 1px solid #20232b;
border-radius: 3px;
}
.board.active { box-shadow: 0 0 0 3px var(--glow, #4a90d9), 0 0 20px 2px var(--glow, #4a90d9); }
.sq {
position: relative; padding: 0; border: 0; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.sq.light { background: #cabf9f; }
.sq.dark { background: #7d6f55; }
.pc {
font-size: calc(var(--sq, 34px) * 0.76); line-height: 1;
text-shadow: -1px -1px 0 #15171c, 1px -1px 0 #15171c,
-1px 1px 0 #15171c, 1px 1px 0 #15171c;
}
.pc.ghost { opacity: 0.42; }
.ghost-sq { outline: 2px dashed #888; outline-offset: -2px; }
.sq.play::after, .sq.local::after {
content: ''; position: absolute; border-radius: 50%;
width: 32%; height: 32%;
}
.sq.play::after { background: #46c24f; box-shadow: 0 0 7px #46c24f; }
.sq.play.occ::after {
width: 84%; height: 84%; background: transparent;
border: 3px solid #46c24f; box-shadow: 0 0 7px #46c24f;
}
.sq.local::after { background: transparent; border: 2px dashed #9aa0aa; }
.sq.selected { outline: 3px solid #3fd9d9; outline-offset: -3px; }
</style>
- Step 2: Typecheck
Run: pnpm exec svelte-check --tsconfig ./tsconfig.json
Expected: 0 errors. (Board.svelte is not yet rendered anywhere — that is fine.)
- Step 3: Commit
git add src/lib/Board.svelte
git commit -m "feat(ui): Board component — rotated grid, coloured pieces, highlights, ghosts"
Task 11: Compass component
Files:
- Create:
src/lib/Compass.svelte
Arranges the four Boards in the pinwheel X with the four player labels in the
V-notches, and wires clicks/highlights from the store.
- Step 1: Write
src/lib/Compass.svelte
<script lang="ts">
import Board from './Board.svelte';
import { gameStore } from './stores/game.svelte';
import { PLAYER_BOARDS } from '../engine/boards';
import type { BoardId, Player, Square } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
// Board centre positions inside the 744x744 compass (see spec §5.1).
const POS: Record<BoardId, { left: number; top: number }> = {
NW: { left: 200, top: 200 }, NE: { left: 544, top: 200 },
SW: { left: 200, top: 544 }, SE: { left: 544, top: 544 },
};
const BOARD_IDS: BoardId[] = ['NW', 'NE', 'SW', 'SE'];
let view = $derived(gameStore.view);
let active = $derived(gameStore.activeBoards);
/** Ghost squares for a given board. */
function ghostsFor(id: BoardId): Square[] {
return view.ghosts.filter((g) => g.board === id).map((g) => g.square);
}
/** Highlight payload for a given board, or null. */
function highlightFor(id: BoardId) {
const h = gameStore.highlight;
if (h === null) return null;
if (id !== h.boardA && id !== h.boardB) return null;
const local = id === h.boardA ? h.onlyA : h.onlyB;
const selectedHere = active.includes(id) ? gameStore.selected : null;
return { playable: h.playable, local, selected: selectedHere };
}
function handleSquare(id: BoardId, square: Square): void {
if (gameStore.isScrubbing) return;
if (!active.includes(id)) return; // only the player-to-move's boards are interactive
if (gameStore.selected === null) {
gameStore.select(square);
} else if (gameStore.highlight?.playable.includes(square)) {
gameStore.commitTo(square);
} else {
gameStore.select(square); // re-grab or cancel
}
}
</script>
<div class="compass" style="--glow:{COLORS[view.currentPlayer]}">
{#each BOARD_IDS as id (id)}
<div class="slot" style="left:{POS[id].left}px; top:{POS[id].top}px;">
<Board
{id}
fen={view.fen[id]}
colors={COLORS}
ghosts={ghostsFor(id)}
highlight={highlightFor(id)}
active={active.includes(id)}
onSquare={(sq) => handleSquare(id, sq)}
/>
</div>
{/each}
<div class="plabel" class:on={view.currentPlayer === 'N'}
style="left:372px; top:74px; background:{COLORS.N}">NORTH</div>
<div class="plabel" class:on={view.currentPlayer === 'S'}
style="left:372px; top:670px; background:{COLORS.S}">SOUTH</div>
<div class="plabel vert" class:on={view.currentPlayer === 'E'}
style="left:670px; top:372px; background:{COLORS.E}">EAST</div>
<div class="plabel vert" class:on={view.currentPlayer === 'W'}
style="left:74px; top:372px; background:{COLORS.W}">WEST</div>
</div>
<style>
.compass { position: relative; width: 744px; height: 744px; flex: none; }
.slot { position: absolute; transform: translate(-50%, -50%); }
.plabel {
position: absolute; transform: translate(-50%, -50%);
color: #fff; font-size: 12px; font-weight: 700; letter-spacing: 0.09em;
padding: 6px 12px; border-radius: 7px; white-space: nowrap;
}
.plabel.vert { writing-mode: vertical-rl; }
.plabel.on { box-shadow: 0 0 14px currentColor; }
</style>
- Step 2: Typecheck
Run: pnpm exec svelte-check --tsconfig ./tsconfig.json
Expected: 0 errors.
- Step 3: Commit
git add src/lib/Compass.svelte
git commit -m "feat(ui): Compass component — pinwheel layout, click-to-move wiring"
Task 12: Panel component
Files:
- Create:
src/lib/Panel.svelte
Turn indicator, coordinate move log, legend, and controls.
- Step 1: Write
src/lib/Panel.svelte
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import { toCoordinate } from '../engine/notation';
import { PLAYERS } from '../engine/boards';
import type { Player } from '../engine/types';
const COLORS: Record<Player, string> = {
N: '#4a90d9', S: '#d6483f', E: '#9d5bd2', W: '#e08a3a',
};
const NAME: Record<Player, string> = { N: 'North', S: 'South', E: 'East', W: 'West' };
let view = $derived(gameStore.view);
/** Move log grouped into rounds of four (N,S,E,W). */
let rounds = $derived.by(() => {
const out: string[][] = [];
view.history.forEach((entry, i) => {
const r = Math.floor(i / 4);
(out[r] ??= [])[i % 4] = toCoordinate(entry);
});
return out;
});
let statusText = $derived.by(() => {
const s = view.status;
if (s.state === 'playing') return `${NAME[view.currentPlayer]} to move`;
if (s.state === 'checkmate') {
const winners = PLAYERS.filter((p) => s.result?.[p] === 'win').map((p) => NAME[p]);
return `Checkmate — ${NAME[view.currentPlayer]} loses; ${winners.join(' & ')} win`;
}
if (s.state === 'stalemate') return 'Stalemate — all draw';
return `Draw (${s.reason})`;
});
let fileInput: HTMLInputElement;
function onFile(e: Event): void {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) gameStore.load(file).catch((err) => alert(String(err)));
}
</script>
<aside class="panel">
<section class="card">
<div class="turn">
<span class="dot" style="background:{COLORS[view.currentPlayer]}"></span>
{statusText}
</div>
<div class="sub">Ghosts on board: {view.ghosts.length} · Checks: {view.status.checks.length}</div>
</section>
<section class="card">
<h2>Move log</h2>
<table>
<thead><tr>
{#each PLAYERS as p}<th style="color:{COLORS[p]}">{NAME[p]}</th>{/each}
</tr></thead>
<tbody>
{#each rounds as round, r (r)}
<tr>
{#each [0, 1, 2, 3] as c}<td>{round[c] ?? ''}</td>{/each}
</tr>
{/each}
</tbody>
</table>
</section>
<section class="card">
<h2>Legend</h2>
<div class="legend">
<div><span class="ring play"></span> Playable — legal on both boards</div>
<div><span class="ring local"></span> Legal on that board only</div>
<div><span class="ring ghost"></span> Ghost — twin captured, frozen</div>
</div>
</section>
<section class="card controls">
<button onclick={() => gameStore.newGame()}>New game</button>
<button onclick={() => gameStore.undo()} disabled={view.ply === 0}>Undo</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) - 1)}
disabled={view.ply === 0}>◀ Prev</button>
<button onclick={() => gameStore.scrubTo((gameStore.scrubPly ?? view.ply) + 1)}
disabled={!gameStore.isScrubbing}>Next ▶</button>
<button onclick={() => gameStore.scrubTo(null)} disabled={!gameStore.isScrubbing}>● Live</button>
<button onclick={() => gameStore.declareDraw()}>Declare draw</button>
<button onclick={() => gameStore.save()}>Save</button>
<button onclick={() => fileInput.click()}>Load</button>
<input type="file" accept="application/json" bind:this={fileInput}
onchange={onFile} style="display:none" />
</section>
</aside>
<style>
.panel { width: 290px; display: flex; flex-direction: column; gap: 13px; }
.card {
background: #1d2027; border: 1px solid #333845;
border-radius: 9px; padding: 13px 15px;
}
.card h2 {
margin: 0 0 8px; font-size: 11px; letter-spacing: 0.07em;
text-transform: uppercase; color: #9aa0aa;
}
.turn { display: flex; align-items: center; gap: 9px; font-weight: 600; }
.dot { width: 13px; height: 13px; border-radius: 50%; }
.sub { font-size: 12px; color: #9aa0aa; margin-top: 6px; }
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 3px 5px; text-align: left; }
th { font-size: 10px; text-transform: uppercase; }
td { font-family: ui-monospace, Menlo, monospace; color: #cdd2da; }
.legend { display: flex; flex-direction: column; gap: 7px; font-size: 12px; }
.legend div { display: flex; align-items: center; gap: 8px; }
.ring { width: 14px; height: 14px; border-radius: 50%; flex: none; }
.ring.play { background: #46c24f; }
.ring.local { border: 2px dashed #9aa0aa; }
.ring.ghost { border: 2px dashed #888; }
.controls { display: flex; flex-wrap: wrap; gap: 6px; }
.controls button {
background: #262b34; color: #e6e8ec; border: 1px solid #333845;
border-radius: 5px; padding: 5px 9px; font-size: 12px; cursor: pointer;
}
.controls button:disabled { opacity: 0.4; cursor: default; }
</style>
- Step 2: Typecheck
Run: pnpm exec svelte-check --tsconfig ./tsconfig.json
Expected: 0 errors.
- Step 3: Commit
git add src/lib/Panel.svelte
git commit -m "feat(ui): Panel component — turn, move log, legend, controls"
Task 13: Promotion dialog
Files:
-
Create:
src/lib/PromotionDialog.svelte -
Step 1: Write
src/lib/PromotionDialog.svelte
<script lang="ts">
import { gameStore } from './stores/game.svelte';
import type { PromotionPiece } from '../engine/types';
const PIECES: { code: PromotionPiece; glyph: string }[] = [
{ code: 'q', glyph: '♛' }, { code: 'r', glyph: '♜' },
{ code: 'b', glyph: '♝' }, { code: 'n', glyph: '♞' },
];
let pending = $derived(gameStore.pendingPromotion);
</script>
{#if pending}
<div class="backdrop" onclick={() => gameStore.cancelPromotion()}
role="presentation">
<div class="dialog" onclick={(e) => e.stopPropagation()} role="presentation">
<h3>Promote pawn ({pending.from}→{pending.to})</h3>
<div class="row">
{#each PIECES as p}
<button onclick={() => gameStore.choosePromotion(p.code)}>{p.glyph}</button>
{/each}
</div>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.dialog {
background: #1d2027; border: 1px solid #333845;
border-radius: 10px; padding: 18px 22px;
}
.dialog h3 { margin: 0 0 12px; font-size: 14px; }
.row { display: flex; gap: 10px; }
.row button {
font-size: 38px; line-height: 1; width: 60px; height: 60px;
background: #262b34; color: #e6e8ec;
border: 1px solid #333845; border-radius: 8px; cursor: pointer;
}
.row button:hover { border-color: #46c24f; }
</style>
- Step 2: Typecheck
Run: pnpm exec svelte-check --tsconfig ./tsconfig.json
Expected: 0 errors.
- Step 3: Commit
git add src/lib/PromotionDialog.svelte
git commit -m "feat(ui): promotion dialog"
Task 14: Assemble the app and smoke-test
Files:
-
Modify:
src/App.svelte,src/app.css -
Create: nothing new
-
Step 1: Write
src/App.svelte
<script lang="ts">
import Compass from './lib/Compass.svelte';
import Panel from './lib/Panel.svelte';
import PromotionDialog from './lib/PromotionDialog.svelte';
</script>
<header>
<h1>Duplicate Chess</h1>
<p>Local sandbox — operator drives all four players. Click a piece on the
glowing boards to see its synchronized-legal moves.</p>
</header>
<main>
<Compass />
<Panel />
</main>
<PromotionDialog />
<style>
header { padding: 14px 22px; border-bottom: 1px solid #333845; }
header h1 { margin: 0; font-size: 17px; }
header p { margin: 4px 0 0; font-size: 12px; color: #9aa0aa; }
main { display: flex; gap: 22px; padding: 20px 22px; align-items: flex-start; }
</style>
- Step 2: Write
src/app.css
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #15171c;
color: #e6e8ec;
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
}
- Step 3: Build and typecheck
pnpm run build
pnpm exec svelte-check --tsconfig ./tsconfig.json
pnpm test
Expected: build succeeds; svelte-check 0 errors; all engine tests pass.
- Step 4: Manual smoke test
Run: pnpm dev, open the printed URL, and verify:
- Four boards render in the pinwheel; North's two boards glow.
- Clicking a North piece shows green dots (playable) and, after divergence, grey dashed dots on one board only.
- Clicking a green dot plays the move on both North boards; the turn advances to South and the glow moves.
- Playing into a capture on one board produces a ghost (faded, dashed) on the twin board.
- Promoting a pawn (advance one to its last rank) opens the dialog; choosing a piece promotes on both boards.
- The move log fills with coordinate tokens; Undo and Prev/Next/Live work.
- Save downloads a JSON file; Load restores it.
- Play a quick Fool's mate (f3/e5/g4/Qh4 symmetrically) and confirm the panel reports the checkmate result.
Fix any issue found, re-running svelte-check after each change.
- Step 5: Commit
git add src/App.svelte src/app.css
git commit -m "feat(ui): assemble the duplicate chess sandbox app"
Self-review notes
- Spec coverage: §2 rules → engine Tasks 3–6; §3 architecture → file structure;
§4 engine → Tasks 3–7; §5 UI → Tasks 10–14; §6 provisional rules → Task 6
(
endgame.ts, markedPROVISIONAL); §7 scope → no networking/AI/editor tasks; §8 testing → Tasks 2–8 (vitest) + Task 14 step 4 (manual). All sections covered. - Type consistency:
SyncMove,HistoryEntry,GameStatus,SelectionHighlight,GameVieware each defined once and imported everywhere;selectionHighlightreturns{boardA,boardB,playable,onlyA,onlyB}andCompassmapsonlyA/onlyBto theBoard'slocalprop consistently. - Provisional rules live only in
endgame.tsand are commentedPROVISIONAL (spec §6)so a future ruling from Andrew is easy to locate. - Deferred (spec §9): rotated-board ergonomics and the 50-move counting unit are
out of scope here;
FIFTY_MOVE_ROUNDSis a single named constant so the unit can be retuned trivially.