code.pablomurad.com

emchess

Browser chess with emoji pieces against Stockfish, no server needed

feat: arena HUD, eval bar, game-over screen, CRT theme

- VS-style HUD: face, captured pieces, material chip, life-bar clocks
- vertical eval bar fed by the engine after each move (queued, never
  interleaved with search)
- last-move arrow for engine moves, drawn on an SVG layer
- checkmate ceremony: board shake, fallen king, end screen with
  rematch / new opponent / menu
- opponent dossier with ASCII stat bars on the new-game screen
- CRT theme (phosphor green, scanlines) and pixel-weight buttons
- new sounds: start, castle, promotion, low-time tick
- drop tsc-emitted .js from src (noEmit) so dev always serves the .ts
- destroy board window listeners when a game ends
author
Pablo Murad
date
commit
6b60c743e0c7b7cfb599852d110f27b229626bcb
parent
e3439f4fd1351a66738e364bb3c2a16038ca6e6a
changes
22 files +573 −1687
@@ -14,9 +14,11 @@ a Web Worker so the board never freezes. The engine is made to pause before it moves, so it looks like it is thinking.
 
 Six opponents, from a careless beginner to a strong master. Four piece
-sets, including a plain Unicode set for maximum clarity. Positions load
-from the URL (?fen=...). Games export to PGN. Everything is stored in
-the browser, and it works offline after the first load.
+sets, including a plain Unicode set for maximum clarity. Two themes:
+plain paper, or phosphor-green CRT with scanlines. An evaluation bar
+and a captured-pieces row track the battle. Positions load from the
+URL (?fen=...). Games export to PGN. Everything is stored in the
+browser, and it works offline after the first load.
 
 
 REQUIREMENTS
@@ -1,311 +0,0 @@-const FILES = "abcdefgh";
-export class Board {
-    constructor(root, opts) {
-        this.opts = opts;
-        this.cells = new Map();
-        this.pieces = new Map();
-        this.selected = null;
-        this.lastMove = null;
-        this.drag = null;
-        this.onPointerDown = (e) => {
-            if (!this.opts.canMove())
-                return;
-            const square = this.squareAt(e.clientX, e.clientY);
-            if (!square)
-                return;
-            if (this.selected && this.selected !== square) {
-                if (this.tryMove(this.selected, square))
-                    return;
-            }
-            const el = this.pieces.get(square);
-            if (el) {
-                this.select(square);
-                this.drag = { from: square, el, moved: false };
-                el.classList.add("dragging");
-                this.dragTo(el, e.clientX, e.clientY);
-            }
-            else {
-                this.clearHints();
-            }
-        };
-        this.onPointerMove = (e) => {
-            if (!this.drag)
-                return;
-            this.drag.moved = true;
-            this.dragTo(this.drag.el, e.clientX, e.clientY);
-        };
-        this.onPointerUp = (e) => {
-            if (!this.drag)
-                return;
-            const { from, el, moved } = this.drag;
-            this.drag = null;
-            el.classList.remove("dragging");
-            this.place(el, from);
-            if (!moved)
-                return;
-            const to = this.squareAt(e.clientX, e.clientY);
-            if (to && to !== from)
-                this.tryMove(from, to);
-        };
-        this.root = root;
-        this.root.classList.add("board");
-        this.buildGrid();
-        this.root.addEventListener("pointerdown", this.onPointerDown);
-        window.addEventListener("pointermove", this.onPointerMove);
-        window.addEventListener("pointerup", this.onPointerUp);
-    }
-    buildGrid() {
-        this.root.replaceChildren();
-        this.cells.clear();
-        for (let rank = 8; rank >= 1; rank--) {
-            for (let f = 0; f < 8; f++) {
-                const file = this.opts.orientation === "w" ? f : 7 - f;
-                const r = this.opts.orientation === "w" ? rank : 9 - rank;
-                const square = (FILES[file] + r);
-                const cell = document.createElement("div");
-                cell.className = "sq " + ((file + r) % 2 === 0 ? "light" : "dark");
-                cell.dataset.square = square;
-                if (this.opts.coords)
-                    this.addCoords(cell, file, r);
-                this.cells.set(square, cell);
-                this.root.appendChild(cell);
-            }
-        }
-    }
-    addCoords(cell, file, rank) {
-        const bottomRank = this.opts.orientation === "w" ? 1 : 8;
-        const edgeFile = this.opts.orientation === "w" ? 0 : 7;
-        if (rank === bottomRank) {
-            const c = document.createElement("span");
-            c.className = "coord file";
-            c.textContent = FILES[file];
-            cell.appendChild(c);
-        }
-        if (file === edgeFile) {
-            const c = document.createElement("span");
-            c.className = "coord rank";
-            c.textContent = String(rank);
-            cell.appendChild(c);
-        }
-    }
-    setPosition(board) {
-        for (const el of this.pieces.values())
-            el.remove();
-        this.pieces.clear();
-        for (const row of board) {
-            for (const cell of row) {
-                if (!cell)
-                    continue;
-                const el = this.makePiece(cell.type, cell.color, cell.square);
-                this.pieces.set(cell.square, el);
-                this.root.appendChild(el);
-            }
-        }
-        this.clearHints();
-    }
-    makePiece(type, color, square) {
-        const el = document.createElement("div");
-        el.className = "piece " + color + (this.opts.set.traditional ? " traditional" : "");
-        const glyph = document.createElement("span");
-        glyph.className = "glyph";
-        glyph.textContent = this.opts.set.glyphs[type];
-        el.appendChild(glyph);
-        el.dataset.type = type;
-        this.place(el, square);
-        return el;
-    }
-    place(el, square) {
-        const { x, y } = this.xy(square);
-        el.style.transform = `translate(${x * 100}%, ${y * 100}%)`;
-    }
-    xy(square) {
-        const file = FILES.indexOf(square[0]);
-        const rank = Number(square[1]);
-        if (this.opts.orientation === "w")
-            return { x: file, y: 8 - rank };
-        return { x: 7 - file, y: rank - 1 };
-    }
-    // Animate a single applied move, including the special cases chess.js flags for us.
-    applyMove(move) {
-        const from = move.from;
-        const to = move.to;
-        const moving = this.pieces.get(from);
-        if (!moving)
-            return this.reconcile(move);
-        if (move.flags.includes("e")) {
-            const capSquare = (to[0] + from[1]);
-            this.capture(capSquare);
-        }
-        else if (move.captured) {
-            this.capture(to);
-        }
-        this.pieces.delete(from);
-        this.pieces.set(to, moving);
-        this.animateFor(moving, move.piece);
-        this.place(moving, to);
-        if (move.flags.includes("p") && move.promotion) {
-            const glyph = moving.querySelector(".glyph");
-            if (glyph)
-                glyph.textContent = this.opts.set.glyphs[move.promotion];
-            moving.dataset.type = move.promotion;
-        }
-        if (move.flags.includes("k"))
-            this.moveRook(move.color, "h", "f");
-        if (move.flags.includes("q"))
-            this.moveRook(move.color, "a", "d");
-        this.setLastMove(from, to);
-        this.clearHints();
-    }
-    reconcile(move) {
-        this.setLastMove(move.from, move.to);
-    }
-    moveRook(color, fromFile, toFile) {
-        const rank = color === "w" ? "1" : "8";
-        const from = (fromFile + rank);
-        const to = (toFile + rank);
-        const rook = this.pieces.get(from);
-        if (!rook)
-            return;
-        this.pieces.delete(from);
-        this.pieces.set(to, rook);
-        this.place(rook, to);
-    }
-    capture(square) {
-        const el = this.pieces.get(square);
-        if (!el)
-            return;
-        this.pieces.delete(square);
-        el.classList.add("captured");
-        setTimeout(() => el.remove(), 220);
-        this.puff(square);
-    }
-    // Each piece carries a little of its own character: pawns hop, the king trudges.
-    animateFor(el, type) {
-        const ms = { p: 220, n: 340, b: 300, r: 340, q: 240, k: 400 };
-        el.style.transitionDuration = (ms[type] ?? 280) + "ms";
-        if (type === "n") {
-            el.classList.add("hop");
-            setTimeout(() => el.classList.remove("hop"), ms.n);
-        }
-    }
-    puff(square) {
-        const p = document.createElement("div");
-        p.className = "puff";
-        p.textContent = "πŸ’¨";
-        this.place(p, square);
-        this.root.appendChild(p);
-        setTimeout(() => p.remove(), 480);
-    }
-    setThreats(squares) {
-        for (const el of this.pieces.values())
-            el.classList.remove("threat");
-        for (const sq of squares)
-            this.pieces.get(sq)?.classList.add("threat");
-    }
-    react(square, text) {
-        const b = document.createElement("div");
-        b.className = "reaction";
-        b.textContent = text;
-        this.place(b, square);
-        this.root.appendChild(b);
-        requestAnimationFrame(() => b.classList.add("rise"));
-        setTimeout(() => b.remove(), 900);
-    }
-    setLastMove(from, to) {
-        if (this.lastMove) {
-            for (const sq of this.lastMove)
-                this.cells.get(sq)?.classList.remove("last");
-        }
-        this.lastMove = [from, to];
-        this.cells.get(from)?.classList.add("last");
-        this.cells.get(to)?.classList.add("last");
-    }
-    setCheck(square) {
-        for (const cell of this.cells.values())
-            cell.classList.remove("check");
-        if (square)
-            this.cells.get(square)?.classList.add("check");
-    }
-    select(square) {
-        this.clearHints();
-        this.selected = square;
-        this.cells.get(square)?.classList.add("sel");
-        for (const move of this.opts.legalTargets(square)) {
-            const cell = this.cells.get(move.to);
-            if (!cell)
-                continue;
-            const dot = document.createElement("div");
-            dot.className = "dot" + (move.captured || move.flags.includes("e") ? " capture" : "");
-            cell.appendChild(dot);
-        }
-    }
-    clearHints() {
-        if (this.selected)
-            this.cells.get(this.selected)?.classList.remove("sel");
-        this.selected = null;
-        for (const cell of this.cells.values()) {
-            cell.querySelectorAll(".dot").forEach((d) => d.remove());
-        }
-    }
-    tryMove(from, to) {
-        const legal = this.opts.legalTargets(from).find((m) => m.to === to);
-        if (!legal)
-            return false;
-        if (legal.flags.includes("p")) {
-            this.askPromotion(legal.color, to, (choice) => this.opts.onMove(from, to, choice));
-        }
-        else {
-            this.opts.onMove(from, to);
-        }
-        return true;
-    }
-    squareAt(clientX, clientY) {
-        const rect = this.root.getBoundingClientRect();
-        const col = Math.floor(((clientX - rect.left) / rect.width) * 8);
-        const row = Math.floor(((clientY - rect.top) / rect.height) * 8);
-        if (col < 0 || col > 7 || row < 0 || row > 7)
-            return null;
-        const file = this.opts.orientation === "w" ? col : 7 - col;
-        const rank = this.opts.orientation === "w" ? 8 - row : row + 1;
-        return (FILES[file] + rank);
-    }
-    dragTo(el, clientX, clientY) {
-        const rect = this.root.getBoundingClientRect();
-        const size = rect.width / 8;
-        const x = clientX - rect.left - size / 2;
-        const y = clientY - rect.top - size / 2;
-        el.style.transform = `translate(${x}px, ${y}px)`;
-    }
-    askPromotion(color, at, done) {
-        const menu = document.createElement("div");
-        menu.className = "promo";
-        const { x, y } = this.xy(at);
-        menu.style.left = `${x * 12.5}%`;
-        menu.style.top = `${Math.min(y, 4) * 12.5}%`;
-        const order = ["q", "r", "b", "n"];
-        for (const type of order) {
-            const b = document.createElement("button");
-            b.textContent = this.opts.set.traditional
-                ? this.opts.set.glyphs[type]
-                : this.opts.set.glyphs[type];
-            b.className = "piece-choice " + color;
-            b.onclick = () => {
-                menu.remove();
-                done(type);
-            };
-            menu.appendChild(b);
-        }
-        this.root.appendChild(menu);
-    }
-    setOrientation(o) {
-        this.opts.orientation = o;
-        this.buildGrid();
-    }
-    setSet(set) {
-        this.opts.set = set;
-    }
-    setCoords(on) {
-        this.opts.coords = on;
-        this.buildGrid();
-    }
-}
@@ -21,6 +21,7 @@ private pieces = new Map<Square, HTMLElement>();   private selected: Square | null = null;
   private lastMove: [Square, Square] | null = null;
   private drag: { from: Square; el: HTMLElement; moved: boolean } | null = null;
+  private arrowSvg!: SVGSVGElement;
 
   constructor(root: HTMLElement, private opts: BoardOptions) {
     this.root = root;
@@ -47,6 +48,11 @@ this.cells.set(square, cell);         this.root.appendChild(cell);
       }
     }
+    // The arrow layer sits above the squares and pieces, redrawn on every rebuild.
+    this.arrowSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
+    this.arrowSvg.setAttribute("viewBox", "0 0 8 8");
+    this.arrowSvg.classList.add("arrows");
+    this.root.appendChild(this.arrowSvg);
   }
 
   private addCoords(cell: HTMLElement, file: number, rank: number) {
@@ -184,16 +190,66 @@ for (const el of this.pieces.values()) el.classList.remove("threat");     for (const sq of squares) this.pieces.get(sq)?.classList.add("threat");
   }
 
-  react(square: Square, text: string) {
+  react(square: Square, text: string, stay = false) {
     const b = document.createElement("div");
-    b.className = "reaction";
+    b.className = "reaction" + (stay ? " stay" : "");
     b.textContent = text;
     this.place(b, square);
     this.root.appendChild(b);
+    if (stay) return;
     requestAnimationFrame(() => b.classList.add("rise"));
     setTimeout(() => b.remove(), 900);
   }
 
+  // An arrow from the engine's last move, so the eye catches what just happened.
+  setArrow(from: Square, to: Square) {
+    this.clearArrow();
+    const a = this.xy(from);
+    const b = this.xy(to);
+    const x1 = a.x + 0.5;
+    const y1 = a.y + 0.5;
+    const x2 = b.x + 0.5;
+    const y2 = b.y + 0.5;
+    const len = Math.hypot(x2 - x1, y2 - y1);
+    if (len === 0) return;
+    const ux = (x2 - x1) / len;
+    const uy = (y2 - y1) / len;
+    const px = -uy;
+    const py = ux;
+    const ex = x2 - ux * 0.36; // arrowhead tip, short of the piece's center
+    const ey = y2 - uy * 0.36;
+    const ns = "http://www.w3.org/2000/svg";
+    const line = document.createElementNS(ns, "line");
+    line.setAttribute("x1", String(x1 + ux * 0.18));
+    line.setAttribute("y1", String(y1 + uy * 0.18));
+    line.setAttribute("x2", String(ex - ux * 0.24));
+    line.setAttribute("y2", String(ey - uy * 0.24));
+    line.setAttribute("class", "arrow");
+    const head = document.createElementNS(ns, "polygon");
+    head.setAttribute(
+      "points",
+      `${ex},${ey} ${ex - ux * 0.3 + px * 0.17},${ey - uy * 0.3 + py * 0.17} ${ex - ux * 0.3 - px * 0.17},${ey - uy * 0.3 - py * 0.17}`
+    );
+    head.setAttribute("class", "arrow");
+    this.arrowSvg.append(line, head);
+  }
+
+  clearArrow() {
+    this.arrowSvg?.replaceChildren();
+  }
+
+  // The beaten king keels over; the board flinches with him.
+  defeat(square: Square) {
+    this.pieces.get(square)?.classList.add("defeated");
+    this.root.classList.add("shake");
+    setTimeout(() => this.root.classList.remove("shake"), 600);
+  }
+
+  clearDefeat() {
+    for (const el of this.pieces.values()) el.classList.remove("defeated");
+    this.root.querySelectorAll(".reaction.stay").forEach((r) => r.remove());
+  }
+
   setLastMove(from: Square, to: Square) {
     if (this.lastMove) {
       for (const sq of this.lastMove) this.cells.get(sq)?.classList.remove("last");
@@ -320,6 +376,13 @@   setOrientation(o: Color) {
     this.opts.orientation = o;
     this.buildGrid();
+  }
+
+  // The board listens on window for drags; drop those listeners when a game ends,
+  // or every rematch would pile another dead board onto the window.
+  destroy() {
+    window.removeEventListener("pointermove", this.onPointerMove);
+    window.removeEventListener("pointerup", this.onPointerUp);
   }
 
   setSet(set: PieceSet) {
@@ -1,32 +0,0 @@-// One fixed glyph per piece type, so a pawn always reads as a pawn.
-const SETS = {
-    kingdom: {
-        id: "kingdom",
-        name: "Kingdom",
-        traditional: false,
-        glyphs: { k: "🀴", q: "πŸ‘Έ", b: "πŸ§™", n: "πŸ‡", r: "🏰", p: "πŸ§‘" },
-    },
-    forest: {
-        id: "forest",
-        name: "Forest",
-        traditional: false,
-        glyphs: { k: "🦁", q: "🦊", b: "πŸ¦‰", n: "🦌", r: "🌳", p: "🐿️" },
-    },
-    gothic: {
-        id: "gothic",
-        name: "Gothic",
-        traditional: false,
-        glyphs: { k: "πŸ§›", q: "πŸ§›β€β™€οΈ", b: "πŸ§™β€β™‚οΈ", n: "🐺", r: "🏰", p: "🧟" },
-    },
-    minimal: {
-        id: "minimal",
-        name: "Minimal",
-        traditional: true,
-        // Color comes from the .w / .b classes, not the glyph.
-        glyphs: { k: "β™š", q: "β™›", b: "♝", n: "β™ž", r: "β™œ", p: "β™Ÿ" },
-    },
-};
-export const PIECE_SETS = Object.values(SETS);
-export function getSet(id) {
-    return SETS[id] ?? SETS.kingdom;
-}
@@ -1,73 +0,0 @@-const VALUE = { p: 1, n: 3, b: 3, r: 5, q: 9, k: 100 };
-export function pieceValue(type) {
-    return VALUE[type] ?? 0;
-}
-export const PIECE_NAME = {
-    p: "pawn",
-    n: "knight",
-    b: "bishop",
-    r: "rook",
-    q: "queen",
-    k: "king",
-};
-// Static exchange evaluation on an occupied square. Returns the material the owner
-// stands to lose if the enemy starts capturing there (positive = a real loss).
-export function exchangeLoss(game, square) {
-    const victim = game.get(square);
-    if (!victim)
-        return 0;
-    const owner = victim.color;
-    const enemy = owner === "w" ? "b" : "w";
-    const attackers = valuesOf(game, game.attackers(square, enemy));
-    const defenders = valuesOf(game, game.attackers(square, owner));
-    if (attackers.length === 0)
-        return 0;
-    return swap(pieceValue(victim.type), attackers, defenders);
-}
-function valuesOf(game, squares) {
-    return squares
-        .map((sq) => pieceValue(game.get(sq).type))
-        .sort((a, b) => a - b);
-}
-// Classic swap-off: least valuable piece captures first, each side may stop when
-// continuing would lose material. Returns net material for the attacking side.
-function swap(target, attackers, defenders) {
-    const gains = [target];
-    let onSquare = attackers[0];
-    let a = 1;
-    let d = 0;
-    let depth = 1;
-    let defenderToMove = true;
-    while (true) {
-        if (defenderToMove) {
-            if (d >= defenders.length)
-                break;
-            gains[depth] = onSquare - gains[depth - 1];
-            onSquare = defenders[d++];
-        }
-        else {
-            if (a >= attackers.length)
-                break;
-            gains[depth] = onSquare - gains[depth - 1];
-            onSquare = attackers[a++];
-        }
-        depth++;
-        defenderToMove = !defenderToMove;
-    }
-    for (let i = depth - 1; i > 0; i--) {
-        gains[i - 1] = -Math.max(-gains[i - 1], gains[i]);
-    }
-    return gains[0];
-}
-// Every piece of `color` the opponent can win material from, worst first.
-export function threats(game, color) {
-    const out = [];
-    for (const { square, type } of game.pieces(color)) {
-        if (type === "k")
-            continue;
-        const loss = exchangeLoss(game, square);
-        if (loss > 0)
-            out.push({ square, type, loss });
-    }
-    return out.sort((a, b) => b.loss - a.loss);
-}
@@ -1,87 +0,0 @@-export const TIME_CONTROLS = [
-    { id: "none", label: "no clock", base: 0, inc: 0 },
-    { id: "1+0", label: "1 + 0   bullet", base: 60, inc: 0 },
-    { id: "3+2", label: "3 + 2   blitz", base: 180, inc: 2 },
-    { id: "5+0", label: "5 + 0   blitz", base: 300, inc: 0 },
-    { id: "10+0", label: "10 + 0   rapid", base: 600, inc: 0 },
-    { id: "15+10", label: "15 + 10   rapid", base: 900, inc: 10 },
-    { id: "30+0", label: "30 + 0   classical", base: 1800, inc: 0 },
-];
-export function timeControl(id, custom) {
-    if (id === "custom" && custom) {
-        return { id: "custom", label: "custom", base: custom.base, inc: custom.inc };
-    }
-    return TIME_CONTROLS.find((t) => t.id === id) ?? TIME_CONTROLS[0];
-}
-export class Clock {
-    constructor(tc, onTick, onFlag) {
-        this.tc = tc;
-        this.onTick = onTick;
-        this.onFlag = onFlag;
-        this.turn = null;
-        this.last = 0;
-        this.flagged = null;
-        this.tick = () => {
-            if (!this.turn || this.flagged)
-                return;
-            const now = performance.now();
-            this.remaining[this.turn] -= now - this.last;
-            this.last = now;
-            if (this.remaining[this.turn] <= 0) {
-                this.remaining[this.turn] = 0;
-                this.flagged = this.turn;
-                this.turn = null;
-                this.stop();
-                this.onTick(this.remaining.w, this.remaining.b);
-                this.onFlag(this.flagged);
-                return;
-            }
-            this.onTick(this.remaining.w, this.remaining.b);
-        };
-        this.remaining = { w: tc.base * 1000, b: tc.base * 1000 };
-        this.incMs = tc.inc * 1000;
-    }
-    get enabled() {
-        return this.tc.base > 0;
-    }
-    start(side) {
-        if (!this.enabled)
-            return;
-        this.turn = side;
-        this.last = performance.now();
-        this.run();
-    }
-    // The side that just moved gets the increment; the other side's clock starts running.
-    press(mover) {
-        if (!this.enabled || this.flagged)
-            return;
-        this.remaining[mover] += this.incMs;
-        this.turn = mover === "w" ? "b" : "w";
-        this.last = performance.now();
-        this.onTick(this.remaining.w, this.remaining.b);
-        this.run();
-    }
-    pause() {
-        this.stop();
-        this.turn = null;
-    }
-    stop() {
-        if (this.timer) {
-            clearInterval(this.timer);
-            this.timer = undefined;
-        }
-    }
-    run() {
-        if (!this.timer)
-            this.timer = setInterval(this.tick, 100);
-    }
-}
-export function formatTime(ms) {
-    if (ms < 0)
-        ms = 0;
-    if (ms < 10000)
-        return (Math.floor(ms / 100) / 10).toFixed(1);
-    const s = Math.ceil(ms / 1000);
-    const m = Math.floor(s / 60);
-    return m + ":" + String(s % 60).padStart(2, "0");
-}
@@ -45,6 +45,11 @@ get enabled() {     return this.tc.base > 0;
   }
 
+  // The initial time in ms, so the UI can render how much of the clock is left.
+  get baseMs() {
+    return this.tc.base * 1000;
+  }
+
   start(side: Color) {
     if (!this.enabled) return;
     this.turn = side;
@@ -1,77 +0,0 @@-import { threats, exchangeLoss, pieceValue, PIECE_NAME } from "./analysis";
-// Warn before a move that drops material the opponent can simply take. Returns the
-// piece at risk, or null if the move is fine. Checks are skipped β€” a check usually
-// changes the tactics.
-export function blunderCheck(game, from, to, promotion, human) {
-    const test = game.clone();
-    const move = test.play(from, to, promotion);
-    if (!move || test.inCheck())
-        return null;
-    const gained = move.captured ? pieceValue(move.captured) : 0;
-    const worst = threats(test, human)[0];
-    if (worst && worst.loss - gained >= 2)
-        return worst;
-    return null;
-}
-export function assess(cp, mate) {
-    if (mate !== null) {
-        return mate > 0 ? `You have a forced mate in ${mate}.` : `You are getting mated in ${Math.abs(mate)}.`;
-    }
-    if (cp === null)
-        return "The position is unclear.";
-    const p = cp / 100;
-    if (p >= 2.5)
-        return "You have a winning advantage.";
-    if (p >= 0.9)
-        return "You stand a little better.";
-    if (p > -0.9)
-        return "The position is about equal.";
-    if (p > -2.5)
-        return "You stand worse.";
-    return "You are in serious trouble.";
-}
-export function explainPosition(game, human, cp, mate) {
-    const enemy = human === "w" ? "b" : "w";
-    const mine = threats(game, human);
-    const theirs = threats(game, enemy);
-    let detail;
-    if (game.inCheck() && game.turn === human) {
-        detail = "Your king is in check, so answer that first.";
-    }
-    else if (mine[0] && mine[0].loss >= 2) {
-        detail = `Your ${PIECE_NAME[mine[0].type]} on ${mine[0].square} is hanging.`;
-    }
-    else if (theirs[0] && theirs[0].loss >= 2) {
-        detail = `You can win the ${PIECE_NAME[theirs[0].type]} on ${theirs[0].square}.`;
-    }
-    else {
-        detail = developmentHint(game, human);
-    }
-    return assess(cp, mate) + " " + detail;
-}
-// Explain, in plain terms, why the last move lost ground. Returns null when there is
-// nothing concrete to say.
-export function explainMistake(before, after, move, human) {
-    const beforeHanging = new Set(threats(before, human).map((t) => t.square));
-    const newHang = threats(after, human).find((t) => !beforeHanging.has(t.square) && t.loss >= 2);
-    if (newHang) {
-        return `That left your ${PIECE_NAME[newHang.type]} on ${newHang.square} attacked and underdefended.`;
-    }
-    const movedLoss = exchangeLoss(after, move.to);
-    if (movedLoss >= 2) {
-        return `Your ${PIECE_NAME[move.piece]} walked into a loss on ${move.to}.`;
-    }
-    return null;
-}
-function developmentHint(game, human) {
-    const rank = human === "w" ? "1" : "8";
-    const home = game
-        .pieces(human)
-        .filter((p) => (p.type === "n" || p.type === "b") && p.square[1] === rank);
-    if (home.length >= 2)
-        return "Bring your knights and bishops into the game.";
-    const enemy = human === "w" ? "b" : "w";
-    if (game.inCheck() && game.turn === enemy)
-        return "You are giving check.";
-    return "Nothing is loose. Look for a plan and improve your worst piece.";
-}
@@ -1,183 +0,0 @@-// A real same-origin URL (not a blob), so Stockfish resolves its .wasm next to itself.
-// The server must send the .wasm as application/wasm.
-const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href;
-export class Engine {
-    constructor() {
-        this.listeners = [];
-        this.worker = new Worker(ENGINE_URL);
-        this.worker.onmessage = (e) => {
-            const line = typeof e.data === "string" ? e.data : String(e.data?.data ?? "");
-            for (const fn of this.listeners)
-                fn(line);
-        };
-        this.ready = this.boot();
-    }
-    send(cmd) {
-        this.worker.postMessage(cmd);
-    }
-    waitFor(token, timeout = 0) {
-        return new Promise((resolve, reject) => {
-            let timer;
-            const fn = (line) => {
-                if (line.includes(token)) {
-                    this.listeners = this.listeners.filter((l) => l !== fn);
-                    if (timer)
-                        clearTimeout(timer);
-                    resolve();
-                }
-            };
-            this.listeners.push(fn);
-            if (timeout > 0) {
-                timer = setTimeout(() => {
-                    this.listeners = this.listeners.filter((l) => l !== fn);
-                    reject(new Error("engine timeout waiting for " + token));
-                }, timeout);
-            }
-        });
-    }
-    async boot() {
-        this.send("uci");
-        await this.waitFor("uciok", 20000);
-        this.send("isready");
-        await this.waitFor("readyok", 20000);
-    }
-    async configure(p) {
-        try {
-            await this.ready;
-        }
-        catch {
-            return;
-        }
-        this.send("setoption name Skill Level value " + p.skill);
-        this.send("setoption name MultiPV value " + Math.max(1, p.multipv));
-        this.send("isready");
-        await this.waitFor("readyok", 8000).catch(() => { });
-    }
-    // Returns a move in UCI form, never before the personality's think floor. Falls back
-    // to a legal move if the engine is unavailable, so the game can never hang.
-    async chooseMove(fen, p, legal) {
-        const started = performance.now();
-        const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]);
-        let move;
-        let alive = true;
-        try {
-            await this.ready;
-        }
-        catch {
-            alive = false;
-        }
-        if (!alive) {
-            move = randomOf(legal);
-        }
-        else if (legal.length && Math.random() < p.blunderChance) {
-            move = randomOf(legal);
-        }
-        else {
-            move = (await this.search(fen, p)) ?? randomOf(legal);
-        }
-        const elapsed = performance.now() - started;
-        if (elapsed < floor)
-            await sleep(floor - elapsed);
-        return move;
-    }
-    search(fen, p) {
-        return new Promise((resolve) => {
-            const candidates = new Map();
-            let done = false;
-            const finish = (m) => {
-                if (done)
-                    return;
-                done = true;
-                clearTimeout(timer);
-                this.listeners = this.listeners.filter((l) => l !== fn);
-                resolve(m);
-            };
-            const fn = (line) => {
-                if (line.startsWith("info")) {
-                    const pv = line.match(/multipv (\d+).*? pv (\w+)/);
-                    if (pv)
-                        candidates.set(Number(pv[1]), pv[2]);
-                }
-                else if (line.startsWith("bestmove")) {
-                    const best = line.split(" ")[1];
-                    finish(best === "(none)" ? null : pick(candidates, best, p.multipv));
-                }
-            };
-            const timer = setTimeout(() => finish(null), p.movetime + 4000);
-            this.listeners.push(fn);
-            this.send("setoption name Skill Level value " + p.skill);
-            this.send("setoption name MultiPV value " + Math.max(1, p.multipv));
-            this.send("position fen " + fen);
-            this.send("go movetime " + p.movetime);
-        });
-    }
-    // Full-strength evaluation for the coach, from the side-to-move's point of view.
-    async evaluate(fen, movetime = 500) {
-        try {
-            await this.ready;
-        }
-        catch {
-            return { cp: null, mate: null };
-        }
-        return new Promise((resolve) => {
-            let cp = null;
-            let mate = null;
-            let done = false;
-            const finish = () => {
-                if (done)
-                    return;
-                done = true;
-                clearTimeout(timer);
-                this.listeners = this.listeners.filter((l) => l !== fn);
-                resolve({ cp, mate });
-            };
-            const fn = (line) => {
-                if (line.startsWith("info")) {
-                    const s = line.match(/score (cp|mate) (-?\d+)/);
-                    if (s) {
-                        if (s[1] === "cp") {
-                            cp = Number(s[2]);
-                            mate = null;
-                        }
-                        else {
-                            mate = Number(s[2]);
-                            cp = null;
-                        }
-                    }
-                }
-                else if (line.startsWith("bestmove")) {
-                    finish();
-                }
-            };
-            const timer = setTimeout(finish, movetime + 4000);
-            this.listeners.push(fn);
-            this.send("setoption name Skill Level value 20");
-            this.send("setoption name MultiPV value 1");
-            this.send("position fen " + fen);
-            this.send("go movetime " + movetime);
-        });
-    }
-    destroy() {
-        this.send("quit");
-        this.worker.terminate();
-    }
-}
-function pick(candidates, best, multipv) {
-    if (multipv <= 1 || candidates.size <= 1)
-        return best;
-    const weights = [0.62, 0.26, 0.09, 0.03];
-    const roll = Math.random();
-    let acc = 0;
-    for (let i = 1; i <= Math.min(multipv, candidates.size); i++) {
-        acc += weights[i - 1] ?? 0.02;
-        if (roll < acc && candidates.has(i))
-            return candidates.get(i);
-    }
-    return best;
-}
-function randomOf(moves) {
-    return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null;
-}
-function sleep(ms) {
-    return new Promise((r) => setTimeout(r, ms));
-}
@@ -1,77 +0,0 @@-export const PERSONALITIES = [
-    {
-        id: "beginner",
-        face: "🌱",
-        name: "The Beginner",
-        blurb: "Plays slowly and makes clear mistakes.",
-        skill: 0,
-        movetime: 120,
-        think: [700, 1400],
-        blunderChance: 0.28,
-        multipv: 4,
-        contempt: 0,
-    },
-    {
-        id: "patient",
-        face: "🐒",
-        name: "The Patient",
-        blurb: "Defensive, avoids risks, trades when it can.",
-        skill: 4,
-        movetime: 200,
-        think: [800, 1500],
-        blunderChance: 0.1,
-        multipv: 3,
-        contempt: -30,
-    },
-    {
-        id: "trickster",
-        face: "🦊",
-        name: "The Trickster",
-        blurb: "Hunts for forks, traps and quick attacks.",
-        skill: 8,
-        movetime: 300,
-        think: [600, 1200],
-        blunderChance: 0.06,
-        multipv: 2,
-        contempt: 40,
-    },
-    {
-        id: "guardian",
-        face: "πŸ›‘οΈ",
-        name: "The Guardian",
-        blurb: "Values safety and a solid structure.",
-        skill: 12,
-        movetime: 400,
-        think: [700, 1300],
-        blunderChance: 0.03,
-        multipv: 1,
-        contempt: -20,
-    },
-    {
-        id: "aggressor",
-        face: "πŸ”₯",
-        name: "The Aggressor",
-        blurb: "Attacks early and accepts sacrifices.",
-        skill: 15,
-        movetime: 500,
-        think: [500, 1100],
-        blunderChance: 0.02,
-        multipv: 1,
-        contempt: 80,
-    },
-    {
-        id: "master",
-        face: "πŸ‘οΈ",
-        name: "The Master",
-        blurb: "Strong and precise. Shows no mercy.",
-        skill: 20,
-        movetime: 1000,
-        think: [500, 900],
-        blunderChance: 0,
-        multipv: 1,
-        contempt: 0,
-    },
-];
-export function getPersonality(id) {
-    return PERSONALITIES.find((p) => p.id === id) ?? PERSONALITIES[0];
-}
@@ -1,117 +0,0 @@-import { Chess } from "chess.js";
-export class Game {
-    constructor(fen) {
-        this.chess = new Chess(fen);
-    }
-    get fen() {
-        return this.chess.fen();
-    }
-    get turn() {
-        return this.chess.turn();
-    }
-    board() {
-        return this.chess.board();
-    }
-    legalFrom(square) {
-        return this.chess.moves({ square, verbose: true });
-    }
-    legalTargets(square) {
-        return this.legalFrom(square);
-    }
-    allMoves() {
-        return this.chess.moves({ verbose: true });
-    }
-    legalUci() {
-        return this.allMoves().map((m) => m.from + m.to + (m.promotion ?? ""));
-    }
-    // Accepts a UCI-style from/to (plus promotion) and returns the applied move, or null.
-    play(from, to, promotion) {
-        try {
-            return this.chess.move({ from, to, promotion: promotion ?? "q" });
-        }
-        catch {
-            return null;
-        }
-    }
-    playUci(uci) {
-        const from = uci.slice(0, 2);
-        const to = uci.slice(2, 4);
-        const promo = uci.length > 4 ? uci[4] : undefined;
-        return this.play(from, to, promo);
-    }
-    undo() {
-        return this.chess.undo();
-    }
-    history() {
-        return this.chess.history({ verbose: true });
-    }
-    inCheck() {
-        return this.chess.inCheck();
-    }
-    get(square) {
-        return this.chess.get(square);
-    }
-    attackers(square, color) {
-        return this.chess.attackers(square, color);
-    }
-    clone() {
-        return new Game(this.fen);
-    }
-    pieces(color) {
-        const out = [];
-        for (const row of this.chess.board()) {
-            for (const cell of row) {
-                if (cell && cell.color === color)
-                    out.push({ square: cell.square, type: cell.type });
-            }
-        }
-        return out;
-    }
-    // The square of the side-to-move's king, used to paint the check marker.
-    kingSquare(color) {
-        for (const row of this.chess.board()) {
-            for (const cell of row) {
-                if (cell && cell.type === "k" && cell.color === color)
-                    return cell.square;
-            }
-        }
-        return null;
-    }
-    outcome() {
-        if (!this.chess.isGameOver())
-            return { over: false };
-        if (this.chess.isCheckmate()) {
-            return { over: true, reason: "checkmate", winner: this.turn === "w" ? "b" : "w" };
-        }
-        if (this.chess.isStalemate())
-            return { over: true, reason: "stalemate" };
-        if (this.chess.isInsufficientMaterial())
-            return { over: true, reason: "insufficient" };
-        if (this.chess.isThreefoldRepetition())
-            return { over: true, reason: "repetition" };
-        if (this.chess.isDrawByFiftyMoves?.())
-            return { over: true, reason: "fifty" };
-        return { over: true, reason: "draw" };
-    }
-    pgn() {
-        return this.chess.pgn();
-    }
-    loadPgn(pgn) {
-        try {
-            this.chess.loadPgn(pgn);
-            return true;
-        }
-        catch {
-            return false;
-        }
-    }
-    load(fen) {
-        try {
-            this.chess.load(fen);
-            return true;
-        }
-        catch {
-            return false;
-        }
-    }
-}
@@ -1,188 +0,0 @@-import "./style.css";
-import { PERSONALITIES, getPersonality } from "./game/personalities";
-import { PIECE_SETS } from "./board/pieces";
-import { TIME_CONTROLS, timeControl } from "./game/clock";
-import { loadPrefs, savePrefs } from "./util/storage";
-import { fenFromUrl, routeFromUrl, goto } from "./util/url";
-const app = document.querySelector("#app");
-let prefs = loadPrefs();
-function main() {
-    window.addEventListener("hashchange", render);
-    render();
-}
-function render() {
-    prefs = loadPrefs();
-    const route = routeFromUrl();
-    const startFen = fenFromUrl();
-    if (route === "play" || startFen)
-        return renderSetup(startFen);
-    if (route === "settings")
-        return renderSettings();
-    if (route === "about")
-        return renderAbout();
-    renderHome();
-}
-function renderHome() {
-    app.innerHTML = `
-    <div class="home">
-      <pre class="logo" aria-label="Emchess">β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•—   β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•—  β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
-β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β•β•β•
-β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—  β–ˆβ–ˆβ•”β–ˆβ–ˆβ–ˆβ–ˆβ•”β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—
-β–ˆβ–ˆβ•”β•β•β•  β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β•  β•šβ•β•β•β•β–ˆβ–ˆβ•‘β•šβ•β•β•β•β–ˆβ–ˆβ•‘
-β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β•šβ•β• β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘
-β•šβ•β•β•β•β•β•β•β•šβ•β•     β•šβ•β• β•šβ•β•β•β•β•β•β•šβ•β•  β•šβ•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•β•β•β•β•β•</pre>
-      <p class="kicker">a quiet game of kings</p>
-      <nav class="menu">
-        <button data-go="play">play</button>
-        <button data-go="settings">settings</button>
-        <button data-go="about">about</button>
-      </nav>
-    </div>`;
-    app.querySelectorAll("[data-go]").forEach((b) => {
-        b.onclick = () => goto(b.dataset.go);
-    });
-}
-function renderSetup(startFen) {
-    let side = prefs.side;
-    let personality = prefs.personality;
-    let clockId = prefs.clock;
-    const opponentRows = PERSONALITIES.map((p) => `
-    <button class="row" data-id="${p.id}" aria-pressed="${p.id === personality}">
-      <span class="cur"> </span>
-      <span class="face">${p.face}</span>
-      <span class="name">${p.name}</span>
-      <span class="blurb">${p.blurb}</span>
-      <span class="leader"></span>
-    </button>`).join("");
-    const timeButtons = [...TIME_CONTROLS, { id: "custom", label: "custom" }]
-        .map((t) => `<button data-tc="${t.id}" aria-pressed="${t.id === clockId}">${t.label}</button>`)
-        .join("");
-    app.innerHTML = `
-    <div class="topbar">
-      <h2>new game</h2>
-      <button class="back" data-back>← menu</button>
-    </div>
-    <div class="setup">
-      <div class="field">
-        <label>your side</label>
-        <div class="toggle" id="sides">
-          <button data-side="w" aria-pressed="${side === "w"}">white</button>
-          <button data-side="b" aria-pressed="${side === "b"}">black</button>
-          <button data-side="random" aria-pressed="${side === "random"}">random</button>
-        </div>
-      </div>
-      <div class="field">
-        <label>opponent</label>
-        <div class="pick" id="opps">${opponentRows}</div>
-      </div>
-      <div class="field">
-        <label>time</label>
-        <div class="toggle" id="times">${timeButtons}</div>
-        <div class="custom ${clockId === "custom" ? "" : "hidden"}" id="custom">
-          <label>min <input id="cbase" type="number" min="1" max="180" value="${Math.round(prefs.clockBase / 60)}"></label>
-          <label>+sec <input id="cinc" type="number" min="0" max="60" value="${prefs.clockInc}"></label>
-        </div>
-      </div>
-      <div class="field">
-        <label>start from (optional)</label>
-        <textarea id="load" class="load" rows="2" placeholder="Paste a FEN or PGN">${startFen ?? ""}</textarea>
-        <div class="soft" id="loaderr"></div>
-      </div>
-      <button class="start" id="start">start</button>
-    </div>`;
-    app.querySelector("[data-back]").onclick = () => goto("home");
-    pressGroup("#sides button", (b) => (side = b.dataset.side));
-    pressGroup("#opps .row", (b) => (personality = b.dataset.id));
-    pressGroup("#times button", (b) => {
-        clockId = b.dataset.tc;
-        app.querySelector("#custom").classList.toggle("hidden", clockId !== "custom");
-    });
-    app.querySelector("#start").onclick = async () => {
-        const base = (Number(app.querySelector("#cbase").value) || 5) * 60;
-        const inc = Number(app.querySelector("#cinc").value) || 0;
-        const text = app.querySelector("#load").value;
-        prefs = { ...prefs, side, personality, clock: clockId, clockBase: base, clockInc: inc };
-        savePrefs(prefs);
-        const human = side === "random" ? (Math.random() < 0.5 ? "w" : "b") : side;
-        const tc = timeControl(clockId, { base, inc });
-        const { startGame } = await import("./play");
-        const err = await startGame(human, getPersonality(personality), tc, text);
-        if (err) {
-            const box = app.querySelector("#loaderr");
-            if (box)
-                box.textContent = err;
-        }
-    };
-}
-function pressGroup(selector, onPick) {
-    const buttons = app.querySelectorAll(selector);
-    buttons.forEach((b) => {
-        b.onclick = () => {
-            onPick(b);
-            buttons.forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
-        };
-    });
-}
-function renderSettings() {
-    const setRows = PIECE_SETS.map((s) => `<button data-set="${s.id}" aria-pressed="${s.id === prefs.pieceSet}">${s.name.toLowerCase()}</button>`).join("");
-    app.innerHTML = `
-    <div class="topbar">
-      <h2>settings</h2>
-      <button class="back" data-back>← menu</button>
-    </div>
-    <div class="setup">
-      <div class="field">
-        <label>pieces</label>
-        <div class="toggle" id="sets">${setRows}</div>
-      </div>
-      <div class="field">
-        <label>board</label>
-        <div class="toggle">
-          <button data-flag="coords" aria-pressed="${prefs.coords}">coordinates</button>
-          <button data-flag="sound" aria-pressed="${prefs.sound}">sound</button>
-          <button data-flag="reactions" aria-pressed="${prefs.reactions}">reactions</button>
-        </div>
-      </div>
-      <div class="field">
-        <label>coach by default</label>
-        <div class="toggle">
-          <button data-flag="threats" aria-pressed="${prefs.threats}">threats</button>
-          <button data-flag="blunder" aria-pressed="${prefs.blunder}">blunder warning</button>
-        </div>
-      </div>
-    </div>`;
-    app.querySelector("[data-back]").onclick = () => goto("home");
-    app.querySelectorAll("#sets button").forEach((b) => {
-        b.onclick = () => {
-            prefs = { ...prefs, pieceSet: b.dataset.set };
-            savePrefs(prefs);
-            app.querySelectorAll("#sets button").forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
-        };
-    });
-    app.querySelectorAll("[data-flag]").forEach((b) => {
-        const key = b.dataset.flag;
-        b.onclick = () => {
-            prefs = { ...prefs, [key]: !prefs[key] };
-            savePrefs(prefs);
-            b.setAttribute("aria-pressed", String(prefs[key]));
-        };
-    });
-}
-function renderAbout() {
-    app.innerHTML = `
-    <div class="topbar">
-      <h2>about</h2>
-      <button class="back" data-back>← menu</button>
-    </div>
-    <div class="about">
-      <p>Emchess is a small chess game you play in your browser. No accounts, no ads, nothing to install. Just a board, a good opponent, and some quiet.</p>
-      <div class="card">
-        Emchess<br>
-        Created by Pablo Murad<br>
-        pablomurad[at]pm[dot]me
-      </div>
-      <p class="kill">Kill the king β€” MMXXVI</p>
-    </div>`;
-    app.querySelector("[data-back]").onclick = () => goto("home");
-}
-main();
@@ -1,6 +1,6 @@ import "./style.css";
 import type { Color } from "./game/state";
-import { PERSONALITIES, getPersonality } from "./game/personalities";
+import { PERSONALITIES, getPersonality, type Personality } from "./game/personalities";
 import { PIECE_SETS } from "./board/pieces";
 import { TIME_CONTROLS, timeControl } from "./game/clock";
 import { loadPrefs, savePrefs, type Prefs } from "./util/storage";
@@ -10,8 +10,13 @@ const app = document.querySelector<HTMLDivElement>("#app")!; let prefs = loadPrefs();
 
 function main() {
+  applyTheme();
   window.addEventListener("hashchange", render);
   render();
+}
+
+function applyTheme() {
+  document.documentElement.dataset.theme = prefs.theme;
 }
 
 function render() {
@@ -83,6 +88,7 @@ <div class="field">         <label>opponent</label>
         <div class="pick" id="opps">${opponentRows}</div>
       </div>
+      <div class="dossier" id="dossier"></div>
       <div class="field">
         <label>time</label>
         <div class="toggle" id="times">${timeButtons}</div>
@@ -102,7 +108,11 @@   app.querySelector<HTMLButtonElement>("[data-back]")!.onclick = () => goto("home");
 
   pressGroup("#sides button", (b) => (side = b.dataset.side as Prefs["side"]));
-  pressGroup("#opps .row", (b) => (personality = b.dataset.id!));
+  pressGroup("#opps .row", (b) => {
+    personality = b.dataset.id!;
+    renderDossier(getPersonality(personality));
+  });
+  renderDossier(getPersonality(personality));
   pressGroup("#times button", (b) => {
     clockId = b.dataset.tc!;
     app.querySelector("#custom")!.classList.toggle("hidden", clockId !== "custom");
@@ -135,6 +145,31 @@ };   });
 }
 
+// A character-select dossier: the opponent's face, pitch, and three arcade stat bars.
+function statBar(v: number): string {
+  const n = Math.max(0, Math.min(10, Math.round(v * 10)));
+  return "β–“".repeat(n) + "β–‘".repeat(10 - n);
+}
+
+function renderDossier(p: Personality) {
+  const el = app.querySelector("#dossier");
+  if (!el) return;
+  const power = p.skill / 20;
+  const speed = 1 - (p.movetime - 120) / (1000 - 120);
+  const chaos = p.blunderChance / 0.28;
+  el.innerHTML = `
+    <span class="d-face">${p.face}</span>
+    <div class="d-body">
+      <div class="d-name">${p.name}</div>
+      <div class="d-blurb">${p.blurb}</div>
+      <div class="d-stats">
+        <span>power ${statBar(power)}</span>
+        <span>speed ${statBar(speed)}</span>
+        <span>chaos ${statBar(chaos)}</span>
+      </div>
+    </div>`;
+}
+
 function renderSettings() {
   const setRows = PIECE_SETS.map(
     (s) => `<button data-set="${s.id}" aria-pressed="${s.id === prefs.pieceSet}">${s.name.toLowerCase()}</button>`
@@ -151,6 +186,13 @@ <label>pieces</label>         <div class="toggle" id="sets">${setRows}</div>
       </div>
       <div class="field">
+        <label>theme</label>
+        <div class="toggle" id="themes">
+          <button data-theme="paper" aria-pressed="${prefs.theme === "paper"}">paper</button>
+          <button data-theme="crt" aria-pressed="${prefs.theme === "crt"}">crt</button>
+        </div>
+      </div>
+      <div class="field">
         <label>board</label>
         <div class="toggle">
           <button data-flag="coords" aria-pressed="${prefs.coords}">coordinates</button>
@@ -174,6 +216,15 @@ b.onclick = () => {       prefs = { ...prefs, pieceSet: b.dataset.set as Prefs["pieceSet"] };
       savePrefs(prefs);
       app.querySelectorAll("#sets button").forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
+    };
+  });
+
+  app.querySelectorAll<HTMLButtonElement>("#themes button").forEach((b) => {
+    b.onclick = () => {
+      prefs = { ...prefs, theme: b.dataset.theme as Prefs["theme"] };
+      savePrefs(prefs);
+      applyTheme();
+      app.querySelectorAll("#themes button").forEach((c) => c.setAttribute("aria-pressed", String(c === b)));
     };
   });
 
@@ -1,396 +0,0 @@-import { Game } from "./game/state";
-import { Board } from "./board/board";
-import { Engine } from "./game/engine";
-import { getSet } from "./board/pieces";
-import { Clock, formatTime } from "./game/clock";
-import { threats, PIECE_NAME } from "./game/analysis";
-import { explainPosition, explainMistake, blunderCheck } from "./game/coach";
-import { sound } from "./util/sound";
-import { loadPrefs, savePrefs, saveGame, clearGame } from "./util/storage";
-import { goto } from "./util/url";
-const app = document.querySelector("#app");
-let prefs = loadPrefs();
-let engine = null;
-// Entry point for the game screen. Loaded on demand so the home page stays tiny.
-export async function startGame(human, personality, tc, text) {
-    prefs = loadPrefs();
-    const initial = parseInitial(text);
-    if (initial === "invalid")
-        return "That is not a valid FEN or PGN.";
-    await new PlayController(human, personality, tc, initial).mount();
-    return null;
-}
-function parseInitial(text) {
-    const t = text.trim();
-    if (!t)
-        return null;
-    const probe = new Game();
-    if (probe.load(t))
-        return { fen: t };
-    if (probe.loadPgn(t))
-        return { pgn: t };
-    return "invalid";
-}
-class PlayController {
-    constructor(human, personality, tc, initial) {
-        this.human = human;
-        this.personality = personality;
-        this.thinking = false;
-        this.timeLoss = null;
-        this.threatsOn = prefs.threats;
-        this.blunderOn = prefs.blunder;
-        this.beforeHuman = null;
-        this.afterHuman = null;
-        this.humanMove = null;
-        this.pendingBlunder = null;
-        this.onTick = (w, b) => {
-            this.setClock("w", w);
-            this.setClock("b", b);
-        };
-        this.onFlag = (side) => {
-            this.timeLoss = side;
-            this.clock.stop();
-            if (prefs.sound)
-                sound.end();
-            this.update();
-        };
-        this.game = new Game(initial?.fen);
-        if (initial?.pgn)
-            this.game.loadPgn(initial.pgn);
-        this.view = human;
-        this.clock = new Clock(tc, this.onTick, this.onFlag);
-        if (!engine)
-            engine = new Engine();
-    }
-    async mount() {
-        const opp = this.human === "w" ? "b" : "w";
-        const clocksHtml = this.clock.enabled
-            ? `<div class="clocks" id="clocks">
-           <div class="clock" data-c="${opp}"><span class="who">opponent</span><span class="time" id="clk-${opp}">--</span></div>
-           <div class="clock" data-c="${this.human}"><span class="who">you</span><span class="time" id="clk-${this.human}">--</span></div>
-         </div>`
-            : "";
-        app.innerHTML = `
-      <div class="topbar">
-        <h2>${this.personality.face} ${this.personality.name}</h2>
-        <button class="back" data-back>← menu</button>
-      </div>
-      <div class="play">
-        <div class="board-wrap"><div id="board"></div></div>
-        <aside class="panel">
-          ${clocksHtml}
-          <div class="status" id="status"></div>
-          <div class="assist" id="assist">
-            <button id="t-threats" aria-pressed="${this.threatsOn}">threats</button>
-            <button id="t-blunder" aria-pressed="${this.blunderOn}">blunder</button>
-            <button id="explain">explain</button>
-            <button id="why">why?</button>
-          </div>
-          <div class="coach" id="coach"></div>
-          <div class="moves"><ol id="movelist"></ol></div>
-          <div class="controls">
-            <button id="undo">undo</button>
-            <button id="flip">flip</button>
-            <button id="pgn">copy pgn</button>
-            <button id="new">new game</button>
-          </div>
-        </aside>
-      </div>`;
-        app.querySelector("[data-back]").onclick = () => {
-            this.clock.stop();
-            clearGame();
-            goto("home");
-        };
-        this.board = new Board(app.querySelector("#board"), {
-            orientation: this.view,
-            set: getSet(prefs.pieceSet),
-            coords: prefs.coords,
-            legalTargets: (sq) => (this.canMove() ? this.game.legalTargets(sq) : []),
-            canMove: () => this.canMove(),
-            onMove: (from, to, promo) => this.onHumanMove(from, to, promo),
-        });
-        this.board.setPosition(this.game.board());
-        this.wireControls();
-        this.refreshCheck();
-        this.update();
-        if (engine)
-            await engine.configure(this.personality);
-        if (this.clock.enabled)
-            this.clock.start(this.game.turn);
-        if (this.game.turn !== this.human)
-            this.aiMove();
-    }
-    wireControls() {
-        app.querySelector("#undo").onclick = () => this.undo();
-        app.querySelector("#flip").onclick = () => {
-            this.view = this.view === "w" ? "b" : "w";
-            this.board.setOrientation(this.view);
-            this.board.setPosition(this.game.board());
-            this.refreshCheck();
-            this.refreshThreats();
-        };
-        app.querySelector("#pgn").onclick = (e) => {
-            navigator.clipboard?.writeText(this.game.pgn());
-            flash(e.target, "copied", "copy pgn");
-        };
-        app.querySelector("#new").onclick = () => {
-            this.clock.stop();
-            clearGame();
-            goto("play");
-        };
-        const threatsBtn = app.querySelector("#t-threats");
-        threatsBtn.onclick = () => {
-            this.threatsOn = !this.threatsOn;
-            prefs = { ...prefs, threats: this.threatsOn };
-            savePrefs(prefs);
-            threatsBtn.setAttribute("aria-pressed", String(this.threatsOn));
-            this.refreshThreats();
-        };
-        const blunderBtn = app.querySelector("#t-blunder");
-        blunderBtn.onclick = () => {
-            this.blunderOn = !this.blunderOn;
-            prefs = { ...prefs, blunder: this.blunderOn };
-            savePrefs(prefs);
-            blunderBtn.setAttribute("aria-pressed", String(this.blunderOn));
-        };
-        app.querySelector("#explain").onclick = () => this.explain();
-        app.querySelector("#why").onclick = () => this.why();
-        if (this.clock.enabled)
-            app.querySelector("#undo").disabled = true;
-    }
-    canMove() {
-        return (!this.thinking &&
-            !this.pendingBlunder &&
-            !this.timeLoss &&
-            this.game.turn === this.human &&
-            !this.game.outcome().over);
-    }
-    onHumanMove(from, to, promo) {
-        if (this.blunderOn) {
-            const risk = blunderCheck(this.game, from, to, promo, this.human);
-            if (risk) {
-                this.pendingBlunder = { from, to, promo };
-                this.askBlunder(PIECE_NAME[risk.type], risk.square);
-                return;
-            }
-        }
-        this.commitHuman(from, to, promo);
-    }
-    commitHuman(from, to, promo) {
-        this.beforeHuman = this.game.clone();
-        const move = this.game.play(from, to, promo);
-        if (!move)
-            return;
-        this.humanMove = move;
-        this.afterHuman = this.game.clone();
-        this.setCoach("");
-        this.afterMove(move);
-        if (!this.game.outcome().over && !this.timeLoss)
-            this.aiMove();
-    }
-    async aiMove() {
-        if (!engine || this.game.outcome().over || this.timeLoss)
-            return;
-        this.thinking = true;
-        this.showThinking();
-        const uci = await engine.chooseMove(this.game.fen, this.personality, this.game.legalUci());
-        this.thinking = false;
-        if (!uci || this.timeLoss)
-            return this.update();
-        const move = this.game.playUci(uci);
-        if (move)
-            this.afterMove(move);
-        else
-            this.update();
-    }
-    afterMove(move) {
-        this.board.applyMove(move);
-        if (this.clock.enabled)
-            this.clock.press(move.color);
-        this.playSound(move);
-        this.showReactions(move);
-        this.refreshCheck();
-        this.refreshThreats();
-        this.update();
-        this.persist();
-        if (this.game.outcome().over) {
-            this.clock.stop();
-            if (prefs.sound)
-                sound.end();
-        }
-    }
-    undo() {
-        if (this.thinking || this.clock.enabled)
-            return;
-        this.game.undo();
-        if (this.game.turn !== this.human)
-            this.game.undo();
-        this.pendingBlunder = null;
-        this.setCoach("");
-        this.board.setPosition(this.game.board());
-        this.refreshCheck();
-        this.refreshThreats();
-        this.update();
-        this.persist();
-    }
-    playSound(move) {
-        if (!prefs.sound)
-            return;
-        if (move.captured)
-            sound.capture();
-        else
-            sound.move();
-        if (this.game.inCheck())
-            sound.check();
-    }
-    showReactions(move) {
-        if (!prefs.reactions)
-            return;
-        if (move.flags.includes("p"))
-            this.board.react(move.to, "✨");
-        if (this.game.inCheck()) {
-            const king = this.game.kingSquare(this.game.turn);
-            if (king)
-                this.board.react(king, "❗");
-        }
-    }
-    refreshCheck() {
-        this.board.setCheck(this.game.inCheck() ? this.game.kingSquare(this.game.turn) : null);
-    }
-    refreshThreats() {
-        if (this.threatsOn)
-            this.board.setThreats(threats(this.game, this.human).map((t) => t.square));
-        else
-            this.board.setThreats([]);
-    }
-    async explain() {
-        if (!engine)
-            return;
-        this.setCoach("thinking…");
-        const { cp, mate } = await engine.evaluate(this.game.fen);
-        const forHuman = this.game.turn === this.human ? 1 : -1;
-        const cpH = cp === null ? null : cp * forHuman;
-        const mateH = mate === null ? null : mate * forHuman;
-        this.setCoach(explainPosition(this.game, this.human, cpH, mateH));
-    }
-    why() {
-        if (!this.beforeHuman || !this.afterHuman || !this.humanMove) {
-            this.setCoach("Make a move first, then ask.");
-            return;
-        }
-        const reason = explainMistake(this.beforeHuman, this.afterHuman, this.humanMove, this.human);
-        this.setCoach(reason ?? "That move was fine β€” nothing was hanging.");
-    }
-    askBlunder(name, square) {
-        this.setCoach("");
-        const coach = app.querySelector("#coach");
-        coach.innerHTML = `This move leaves your ${name} on ${square} for the taking.
-      <div class="assist" style="margin-top:6px">
-        <button id="playanyway">play it anyway</button>
-        <button id="takeback">take it back</button>
-      </div>`;
-        coach.querySelector("#playanyway").onclick = () => {
-            const p = this.pendingBlunder;
-            this.pendingBlunder = null;
-            coach.textContent = "";
-            this.commitHuman(p.from, p.to, p.promo);
-        };
-        coach.querySelector("#takeback").onclick = () => {
-            this.pendingBlunder = null;
-            coach.textContent = "";
-        };
-    }
-    setCoach(text) {
-        const coach = app.querySelector("#coach");
-        if (coach)
-            coach.textContent = text;
-    }
-    setClock(c, ms) {
-        const el = document.getElementById("clk-" + c);
-        if (!el)
-            return;
-        el.textContent = formatTime(ms);
-        const box = el.parentElement;
-        box.classList.toggle("running", this.game.turn === c && this.canRun());
-        box.classList.toggle("low", ms < 10000 && ms > 0);
-        box.classList.toggle("flag", this.timeLoss === c);
-    }
-    canRun() {
-        return !this.timeLoss && !this.game.outcome().over;
-    }
-    showThinking() {
-        const status = app.querySelector("#status");
-        if (status)
-            status.innerHTML = `<span class="thinking">${this.personality.face} is thinking…</span>`;
-    }
-    update() {
-        this.renderStatus();
-        this.renderMoves();
-        const undo = app.querySelector("#undo");
-        if (undo && !this.clock.enabled)
-            undo.disabled = this.game.history().length === 0;
-    }
-    renderStatus() {
-        const status = app.querySelector("#status");
-        if (!status)
-            return;
-        if (this.timeLoss) {
-            status.textContent =
-                this.timeLoss === this.human ? "You lost on time." : "Your opponent lost on time. You win.";
-            return;
-        }
-        const outcome = this.game.outcome();
-        if (outcome.over) {
-            status.textContent = describeOutcome(outcome, this.human);
-            return;
-        }
-        const dot = `<span class="turn-dot ${this.game.turn}"></span>`;
-        status.innerHTML = dot + (this.game.turn === this.human ? "Your move" : "Waiting…");
-    }
-    renderMoves() {
-        const list = app.querySelector("#movelist");
-        if (!list)
-            return;
-        const history = this.game.history();
-        let html = "";
-        for (let i = 0; i < history.length; i += 2) {
-            const no = i / 2 + 1;
-            const white = history[i]?.san ?? "";
-            const black = history[i + 1]?.san ?? "";
-            const wHere = i === history.length - 1 ? " here" : "";
-            const bHere = i + 1 === history.length - 1 ? " here" : "";
-            html += `<li><span class="no">${no}.</span><span class="san${wHere}">${white}</span><span class="san${bHere}">${black}</span></li>`;
-        }
-        list.innerHTML = html;
-        list.parentElement.scrollTop = list.parentElement.scrollHeight;
-    }
-    persist() {
-        if (this.game.outcome().over || this.timeLoss) {
-            clearGame();
-            return;
-        }
-        saveGame({
-            pgn: this.game.pgn(),
-            side: this.human,
-            personality: this.personality.id,
-            savedAt: Date.now(),
-        });
-    }
-}
-function flash(btn, on, off) {
-    btn.textContent = on;
-    setTimeout(() => (btn.textContent = off), 1200);
-}
-function describeOutcome(outcome, human) {
-    if (outcome.reason === "checkmate") {
-        return outcome.winner === human ? "Checkmate. You win." : "Checkmate. You lose.";
-    }
-    const draws = {
-        stalemate: "Stalemate. It's a draw.",
-        insufficient: "Draw β€” not enough material to mate.",
-        repetition: "Draw by repetition.",
-        fifty: "Draw by the fifty-move rule.",
-        draw: "The game is a draw.",
-    };
-    return draws[outcome.reason] ?? "The game is over.";
-}
@@ -2,7 +2,7 @@ import { Game, type Color, type Square, type Move } from "./game/state"; import { Board } from "./board/board";
 import { Engine } from "./game/engine";
 import type { Personality } from "./game/personalities";
-import { getSet } from "./board/pieces";
+import { getSet, type PieceType } from "./board/pieces";
 import { Clock, formatTime, type TimeControl } from "./game/clock";
 import { threats, PIECE_NAME } from "./game/analysis";
 import { explainPosition, explainMistake, blunderCheck } from "./game/coach";
@@ -15,6 +15,11 @@ const app = document.querySelector<HTMLDivElement>("#app")!;
 let prefs = loadPrefs();
 let engine: Engine | null = null;
+let liveBoard: Board | null = null;
+
+const PIECE_VALUE: Record<string, number> = { p: 1, n: 3, b: 3, r: 5, q: 9 };
+const START_COUNT: Record<string, number> = { p: 8, n: 2, b: 2, r: 2, q: 1 };
+const GRAVEYARD_ORDER: PieceType[] = ["q", "r", "b", "n", "p"];
 
 // Entry point for the game screen. Loaded on demand so the home page stays tiny.
 export async function startGame(
@@ -26,6 +31,8 @@ ): Promise<string | null> {   prefs = loadPrefs();
   const initial = parseInitial(text);
   if (initial === "invalid") return "That is not a valid FEN or PGN.";
+  liveBoard?.destroy();
+  liveBoard = null;
   await new PlayController(human, personality, tc, initial).mount();
   return null;
 }
@@ -46,6 +53,7 @@ private clock: Clock;   private thinking = false;
   private view: Color;
   private timeLoss: Color | null = null;
+  private over = false;
 
   private threatsOn = prefs.threats;
   private blunderOn = prefs.blunder;
@@ -55,10 +63,15 @@ private afterHuman: Game | null = null;   private humanMove: Move | null = null;
   private pendingBlunder: { from: Square; to: Square; promo?: Promo } | null = null;
 
+  // Evaluations are queued so they never interleave with a search on the wire.
+  private evalQueue: Promise<unknown> = Promise.resolve();
+  private evalToken = 0;
+  private lastTickSec = -1;
+
   constructor(
     private human: Color,
     private personality: Personality,
-    tc: TimeControl,
+    private tc: TimeControl,
     initial: { fen?: string; pgn?: string } | null
   ) {
     this.game = new Game(initial?.fen);
@@ -70,12 +83,11 @@ } 
   async mount() {
     const opp = this.human === "w" ? "b" : "w";
-    const clocksHtml = this.clock.enabled
-      ? `<div class="clocks" id="clocks">
-           <div class="clock" data-c="${opp}"><span class="who">opponent</span><span class="time" id="clk-${opp}">--</span></div>
-           <div class="clock" data-c="${this.human}"><span class="who">you</span><span class="time" id="clk-${this.human}">--</span></div>
-         </div>`
-      : "";
+    const glyphs = getSet(prefs.pieceSet).glyphs;
+    const clockHtml = (c: Color) =>
+      this.clock.enabled
+        ? `<span class="clock" data-c="${c}"><span class="bar" id="bar-${c}"></span><span class="time" id="clk-${c}">--</span></span>`
+        : "";
 
     app.innerHTML = `
       <div class="topbar">
@@ -83,9 +95,30 @@ <h2>${this.personality.face} ${this.personality.name}</h2>         <button class="back" data-back>← menu</button>
       </div>
       <div class="play">
-        <div class="board-wrap"><div id="board"></div></div>
+        <div class="arena">
+          <div class="hud opp">
+            <span class="face">${this.personality.face}</span>
+            <span class="who">${this.personality.name}</span>
+            <span class="captured" id="cap-${opp}"></span>
+            <span class="mat" id="mat-${opp}"></span>
+            ${clockHtml(opp)}
+          </div>
+          <div class="board-row">
+            <div class="evalwrap">
+              <div class="evalbar"><div class="evalfill" id="evalfill"></div></div>
+              <span class="evalnum" id="evalnum"></span>
+            </div>
+            <div class="board-wrap"><div id="board"></div></div>
+          </div>
+          <div class="hud you">
+            <span class="face">${glyphs.k}</span>
+            <span class="who">you Β· ${this.human === "w" ? "white" : "black"}</span>
+            <span class="captured" id="cap-${this.human}"></span>
+            <span class="mat" id="mat-${this.human}"></span>
+            ${clockHtml(this.human)}
+          </div>
+        </div>
         <aside class="panel">
-          ${clocksHtml}
           <div class="status" id="status"></div>
           <div class="assist" id="assist">
             <button id="t-threats" aria-pressed="${this.threatsOn}">threats</button>
@@ -102,7 +135,8 @@ <button id="pgn">copy pgn</button>             <button id="new">new game</button>
           </div>
         </aside>
-      </div>`;
+      </div>
+      <div class="gameover hidden" id="gameover"></div>`;
 
     app.querySelector<HTMLButtonElement>("[data-back]")!.onclick = () => {
       this.clock.stop();
@@ -118,6 +152,7 @@ legalTargets: (sq) => (this.canMove() ? this.game.legalTargets(sq) : []),       canMove: () => this.canMove(),
       onMove: (from, to, promo) => this.onHumanMove(from, to, promo as Promo),
     });
+    liveBoard = this.board;
 
     this.board.setPosition(this.game.board());
     this.wireControls();
@@ -125,8 +160,11 @@ this.refreshCheck();     this.update();
 
     if (engine) await engine.configure(this.personality);
+    if (prefs.sound) sound.start();
     if (this.clock.enabled) this.clock.start(this.game.turn);
-    if (this.game.turn !== this.human) this.aiMove();
+    if (this.game.outcome().over) this.showGameOver();
+    else if (this.game.turn !== this.human) void this.aiMove().finally(() => this.refreshEval());
+    else void this.refreshEval();
   }
 
   private wireControls() {
@@ -198,8 +236,11 @@ if (!move) return;     this.humanMove = move;
     this.afterHuman = this.game.clone();
     this.setCoach("");
+    this.board.clearArrow();
     this.afterMove(move);
-    if (!this.game.outcome().over && !this.timeLoss) this.aiMove();
+    if (!this.game.outcome().over && !this.timeLoss) {
+      void this.aiMove().finally(() => this.refreshEval());
+    }
   }
 
   private async aiMove() {
@@ -208,10 +249,13 @@ this.thinking = true;     this.showThinking();
     const uci = await engine.chooseMove(this.game.fen, this.personality, this.game.legalUci());
     this.thinking = false;
+    app.querySelector(".hud.opp")?.classList.remove("thinking");
     if (!uci || this.timeLoss) return this.update();
     const move = this.game.playUci(uci);
-    if (move) this.afterMove(move);
-    else this.update();
+    if (move) {
+      this.board.setArrow(move.from as Square, move.to as Square);
+      this.afterMove(move);
+    } else this.update();
   }
 
   private afterMove(move: Move) {
@@ -226,11 +270,15 @@ this.persist();     if (this.game.outcome().over) {
       this.clock.stop();
       if (prefs.sound) sound.end();
+      this.showGameOver();
     }
   }
 
   private undo() {
     if (this.thinking || this.clock.enabled) return;
+    this.hideGameOver();
+    this.board.clearDefeat();
+    this.board.clearArrow();
     this.game.undo();
     if (this.game.turn !== this.human) this.game.undo();
     this.pendingBlunder = null;
@@ -240,11 +288,14 @@ this.refreshCheck();     this.refreshThreats();
     this.update();
     this.persist();
+    void this.refreshEval();
   }
 
   private playSound(move: Move) {
     if (!prefs.sound) return;
-    if (move.captured) sound.capture();
+    if (move.flags.includes("p")) sound.promote();
+    else if (move.flags.includes("k") || move.flags.includes("q")) sound.castle();
+    else if (move.captured) sound.capture();
     else sound.move();
     if (this.game.inCheck()) sound.check();
   }
@@ -267,10 +318,48 @@ if (this.threatsOn) this.board.setThreats(threats(this.game, this.human).map((t) => t.square));     else this.board.setThreats([]);
   }
 
+  private runEngineEval(fen: string, movetime = 400): Promise<{ cp: number | null; mate: number | null }> {
+    const p = this.evalQueue.then(() =>
+      engine ? engine.evaluate(fen, movetime) : { cp: null, mate: null }
+    );
+    this.evalQueue = p.catch(() => {});
+    return p;
+  }
+
+  // The eval bar reads from White's point of view, no matter who sits where.
+  private async refreshEval() {
+    if (!engine || this.game.outcome().over || this.timeLoss) return;
+    const token = ++this.evalToken;
+    const fen = this.game.fen;
+    const stm = this.game.turn;
+    const { cp, mate } = await this.runEngineEval(fen);
+    if (token !== this.evalToken) return;
+    if (mate !== null) {
+      const m = stm === "w" ? mate : -mate; // from White's side of the board
+      this.renderEval(m > 0 ? 1 : -1, (m > 0 ? "#" : "-#") + Math.abs(m));
+    } else if (cp !== null) {
+      const cpW = stm === "w" ? cp : -cp;
+      const score = (2 / Math.PI) * Math.atan(cpW / 350);
+      this.renderEval(score, (cpW >= 0 ? "+" : "") + (cpW / 100).toFixed(1));
+    }
+  }
+
+  private renderEval(score: number, label: string) {
+    const fill = app.querySelector<HTMLElement>("#evalfill");
+    const num = app.querySelector<HTMLElement>("#evalnum");
+    if (!fill || !num) return;
+    fill.style.height = Math.round(((score + 1) / 2) * 100) + "%";
+    num.textContent = label;
+  }
+
   private async explain() {
     if (!engine) return;
+    if (this.thinking) {
+      this.setCoach("One moment β€” the engine is busy with its move.");
+      return;
+    }
     this.setCoach("thinking…");
-    const { cp, mate } = await engine.evaluate(this.game.fen);
+    const { cp, mate } = await this.runEngineEval(this.game.fen, 500);
     const forHuman = this.game.turn === this.human ? 1 : -1;
     const cpH = cp === null ? null : cp * forHuman;
     const mateH = mate === null ? null : mate * forHuman;
@@ -314,6 +403,16 @@   private onTick = (w: number, b: number) => {
     this.setClock("w", w);
     this.setClock("b", b);
+    const ms = this.human === "w" ? w : b;
+    if (prefs.sound && this.canRun() && this.game.turn === this.human && ms > 0 && ms <= 10000) {
+      const sec = Math.ceil(ms / 1000);
+      if (sec !== this.lastTickSec) {
+        this.lastTickSec = sec;
+        sound.tick();
+      }
+    } else {
+      this.lastTickSec = -1;
+    }
   };
 
   private setClock(c: Color, ms: number) {
@@ -324,6 +423,11 @@ const box = el.parentElement!;     box.classList.toggle("running", this.game.turn === c && this.canRun());
     box.classList.toggle("low", ms < 10000 && ms > 0);
     box.classList.toggle("flag", this.timeLoss === c);
+    const bar = document.getElementById("bar-" + c);
+    if (bar) {
+      const pct = Math.max(0, Math.min(100, (ms / this.clock.baseMs) * 100));
+      bar.style.width = pct + "%";
+    }
   }
 
   private canRun(): boolean {
@@ -335,16 +439,19 @@ this.timeLoss = side;     this.clock.stop();
     if (prefs.sound) sound.end();
     this.update();
+    this.showGameOver();
   };
 
   private showThinking() {
     const status = app.querySelector<HTMLElement>("#status");
     if (status) status.innerHTML = `<span class="thinking">${this.personality.face} is thinking…</span>`;
+    app.querySelector(".hud.opp")?.classList.add("thinking");
   }
 
   private update() {
     this.renderStatus();
     this.renderMoves();
+    this.renderMaterial();
     const undo = app.querySelector<HTMLButtonElement>("#undo");
     if (undo && !this.clock.enabled) undo.disabled = this.game.history().length === 0;
   }
@@ -366,6 +473,43 @@ const dot = `<span class="turn-dot ${this.game.turn}"></span>`;     status.innerHTML = dot + (this.game.turn === this.human ? "Your move" : "Waiting…");
   }
 
+  // Fallen pieces line up next to the side that lost them; the leader gets a +N chip.
+  private renderMaterial() {
+    const set = getSet(prefs.pieceSet);
+    const count: Record<Color, Record<string, number>> = {
+      w: { p: 0, n: 0, b: 0, r: 0, q: 0 },
+      b: { p: 0, n: 0, b: 0, r: 0, q: 0 },
+    };
+    for (const row of this.game.board()) {
+      for (const cell of row) {
+        if (cell && cell.type !== "k") count[cell.color][cell.type]++;
+      }
+    }
+    let valW = 0;
+    let valB = 0;
+    for (const t of GRAVEYARD_ORDER) {
+      valW += count.w[t] * PIECE_VALUE[t];
+      valB += count.b[t] * PIECE_VALUE[t];
+    }
+    for (const c of ["w", "b"] as Color[]) {
+      const el = app.querySelector<HTMLElement>("#cap-" + c);
+      if (el) {
+        let html = "";
+        for (const t of GRAVEYARD_ORDER) {
+          const lost = START_COUNT[t] - count[c][t];
+          const cls = set.traditional ? ` class="cap trad ${c}"` : ` class="cap"`;
+          for (let i = 0; i < lost; i++) html += `<span${cls}>${set.glyphs[t]}</span>`;
+        }
+        el.innerHTML = html;
+      }
+      const mat = app.querySelector<HTMLElement>("#mat-" + c);
+      if (mat) {
+        const diff = c === "w" ? valW - valB : valB - valW;
+        mat.textContent = diff > 0 ? "+" + diff : "";
+      }
+    }
+  }
+
   private renderMoves() {
     const list = app.querySelector<HTMLOListElement>("#movelist");
     if (!list) return;
@@ -381,6 +525,79 @@ html += `<li><span class="no">${no}.</span><span class="san${wHere}">${white}</span><span class="san${bHere}">${black}</span></li>`;     }
     list.innerHTML = html;
     list.parentElement!.scrollTop = list.parentElement!.scrollHeight;
+  }
+
+  private showGameOver() {
+    if (this.over) return;
+    this.over = true;
+
+    let banner = "DRAW";
+    let detail: string;
+    const outcome = this.game.outcome();
+    if (this.timeLoss) {
+      banner = "FLAG FALL";
+      detail = this.timeLoss === this.human ? "You lost on time." : "You win on time.";
+    } else if (outcome.over && outcome.reason === "checkmate") {
+      banner = "CHECKMATE";
+      detail = outcome.winner === this.human ? "You win." : "You lose.";
+      const king = this.game.kingSquare(this.game.turn);
+      if (king) {
+        this.board.defeat(king);
+        this.board.react(king, "πŸ’€", true);
+      }
+      this.renderEval(outcome.winner === "w" ? 1 : -1, outcome.winner === "w" ? "1-0" : "0-1");
+    } else {
+      if (outcome.over && outcome.reason === "stalemate") banner = "STALEMATE";
+      detail = outcome.over ? describeOutcome(outcome, this.human) : "The game is over.";
+    }
+
+    const moves = Math.ceil(this.game.history().length / 2);
+    const box = app.querySelector<HTMLElement>("#gameover")!;
+    box.innerHTML = `
+      <div class="go-box">
+        <pre class="go-banner">${banner}</pre>
+        <p class="go-detail">${detail}</p>
+        <p class="go-meta">${moves} moves Β· ${this.personality.face} ${this.personality.name}</p>
+        <div class="go-actions">
+          <button class="btn" id="rematch">rematch</button>
+          <button class="btn" id="gonew">new opponent</button>
+          <button class="btn ghost" id="gomenu">menu</button>
+        </div>
+        <button class="go-pgn" id="gopgn">[ copy pgn ]</button>
+      </div>`;
+
+    box.querySelector<HTMLButtonElement>("#rematch")!.onclick = () => {
+      this.clock.stop();
+      clearGame();
+      const next: Color = this.human === "w" ? "b" : "w";
+      void startGame(next, this.personality, this.tc, "");
+    };
+    box.querySelector<HTMLButtonElement>("#gonew")!.onclick = () => {
+      this.clock.stop();
+      clearGame();
+      goto("play");
+    };
+    box.querySelector<HTMLButtonElement>("#gomenu")!.onclick = () => {
+      this.clock.stop();
+      clearGame();
+      goto("home");
+    };
+    box.querySelector<HTMLButtonElement>("#gopgn")!.onclick = (e) => {
+      navigator.clipboard?.writeText(this.game.pgn());
+      flash(e.target as HTMLButtonElement, "[ copied ]", "[ copy pgn ]");
+    };
+
+    // Let the final position land β€” the falling king, the skull β€” before the curtain.
+    setTimeout(() => box.classList.remove("hidden"), 700);
+  }
+
+  private hideGameOver() {
+    this.over = false;
+    const box = app.querySelector<HTMLElement>("#gameover");
+    if (box) {
+      box.classList.add("hidden");
+      box.innerHTML = "";
+    }
   }
 
   private persist() {
@@ -153,22 +153,90 @@ } 
 .start {
   margin-top: 10px;
-  font-size: 20px;
+  font-size: 18px;
   letter-spacing: 2px;
-  color: var(--ink);
+  background: var(--ink);
+  color: var(--paper);
+  padding: 10px 22px;
+  box-shadow: 3px 3px 0 var(--line);
+  transition: transform .06s ease, box-shadow .06s ease;
 }
-.start::before { content: "> "; color: var(--accent); }
-.start:hover { color: var(--accent); }
-.start:hover::after { content: "_"; }
+.start::before { content: "> "; }
+.start:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--line); }
+.start:hover::after { content: " _"; }
+.start:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--line); }
 
 /* Play */
 
 .play { display: grid; grid-template-columns: auto 240px; gap: 30px; align-items: start; }
+.arena { display: flex; flex-direction: column; gap: 8px; }
+.board-row { display: flex; gap: 10px; align-items: stretch; }
 @media (max-width: 720px) {
   .play { grid-template-columns: 1fr; }
-  :root { --sq: min(11.4vw, 62px); }
+  :root { --sq: min(10.6vw, 62px); }
+  .board-row { justify-content: center; }
 }
 
+/* HUD β€” one row per combatant: face, name, their fallen, and their clock */
+
+.hud { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; min-height: 26px; }
+.hud .face { font-family: system-ui, "Segoe UI Emoji", sans-serif; font-size: 20px; display: inline-block; }
+.hud .who { color: var(--ink); }
+.hud .captured { display: flex; flex-wrap: wrap; gap: 1px; align-items: baseline; font-family: system-ui, "Segoe UI Emoji", sans-serif; font-size: 15px; line-height: 1; }
+.hud .captured .cap.trad.w { color: #fbf7ec; text-shadow: 0 0 1px #000, 0 1px 1px rgba(0,0,0,.45); }
+.hud .captured .cap.trad.b { color: #141109; }
+.hud .mat { color: var(--accent); font-size: 14px; }
+.hud.thinking .face { animation: bob 0.8s ease infinite; }
+@keyframes bob { 50% { transform: translateY(-3px); } }
+
+/* Clock β€” the digits ride on a draining time bar */
+
+.hud .clock {
+  position: relative;
+  margin-left: auto;
+  overflow: hidden;
+  display: inline-flex;
+  padding: 2px 10px;
+  border: 1px solid var(--line);
+  min-width: 7ch;
+  justify-content: flex-end;
+}
+.clock .bar {
+  position: absolute;
+  left: 0; top: 0; bottom: 0;
+  width: 100%;
+  background: var(--accent);
+  opacity: 0.16;
+  transition: width 0.1s linear;
+}
+.clock .time { position: relative; font-size: 20px; letter-spacing: 1px; }
+.clock.running { border-color: var(--ink); }
+.clock.low .time { color: var(--check); }
+.clock.low { animation: pulse 0.5s step-end infinite; }
+@keyframes pulse { 50% { border-color: var(--check); } }
+.clock.flag { opacity: 0.5; }
+
+/* Eval bar β€” white's share of the position, from the bottom up */
+
+.evalwrap { display: flex; flex-direction: column; align-items: center; gap: 4px; }
+.evalbar {
+  width: 12px;
+  flex: 1;
+  border: 1px solid var(--line);
+  background: #1b1a15;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+}
+.evalfill { width: 100%; height: 50%; background: #f5f1e6; transition: height 0.45s ease; }
+.evalnum { font-family: var(--small); font-size: 12px; color: var(--soft); }
+
+/* Last-move arrow */
+
+.arrows { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 4; pointer-events: none; }
+.arrow { stroke: var(--accent); stroke-width: 0.22; stroke-linecap: round; fill: none; opacity: 0.55; }
+polygon.arrow { fill: var(--accent); stroke: none; }
+
 .board-wrap { width: calc(var(--sq) * 8); }
 .board {
   position: relative;
@@ -287,22 +355,6 @@ transition: transform .8s ease, opacity .8s ease; }
 .reaction.rise { transform: translateY(-40%); opacity: 0; }
 
-/* Clocks */
-
-.clocks { display: flex; flex-direction: column; gap: 6px; }
-.clock {
-  display: flex;
-  justify-content: space-between;
-  align-items: baseline;
-  padding: 6px 10px;
-  border: 1px solid var(--line);
-}
-.clock .who { color: var(--soft); font-size: 14px; }
-.clock .time { font-size: 26px; letter-spacing: 1px; }
-.clock.running { border-color: var(--ink); }
-.clock.low .time { color: var(--check); }
-.clock.flag { opacity: .5; }
-
 /* Assist + coach */
 
 .assist { display: flex; flex-wrap: wrap; gap: 4px 12px; }
@@ -349,3 +401,122 @@ .about { max-width: 560px; line-height: 26px; }
 .about .card { margin-top: 20px; padding-left: 14px; border-left: 2px solid var(--accent); }
 .about .kill { margin-top: 26px; color: var(--accent); letter-spacing: 1px; }
+
+/* Dossier β€” the selected opponent's character sheet */
+
+.dossier {
+  display: flex;
+  gap: 14px;
+  align-items: flex-start;
+  border: 1px solid var(--line);
+  background: var(--panel);
+  padding: 12px 14px;
+  margin: -14px 0 30px;
+}
+.d-face { font-size: 34px; font-family: system-ui, "Segoe UI Emoji", sans-serif; line-height: 1; }
+.d-name { color: var(--ink); }
+.d-blurb { color: var(--soft); font-size: 14px; margin-bottom: 8px; }
+.d-stats { display: flex; flex-direction: column; gap: 2px; font-size: 14px; color: var(--soft); }
+.d-stats span { white-space: pre; }
+
+/* Buttons with some heft */
+
+.btn {
+  font-family: inherit;
+  font-size: 16px;
+  letter-spacing: 1px;
+  background: var(--ink);
+  color: var(--paper);
+  padding: 8px 16px;
+  box-shadow: 3px 3px 0 var(--line);
+  transition: transform .06s ease, box-shadow .06s ease;
+}
+.btn:hover { transform: translate(-1px, -1px); box-shadow: 4px 4px 0 var(--line); }
+.btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 var(--line); }
+.btn.ghost { background: none; color: var(--soft); box-shadow: none; border: 1px solid var(--line); }
+.btn.ghost:hover { color: var(--ink); border-color: var(--ink); transform: none; }
+
+/* The fallen king and the flinching board */
+
+.piece.defeated .glyph {
+  filter: grayscale(1) brightness(.75);
+  transform: rotate(80deg);
+  transition: transform .4s ease, filter .4s ease;
+}
+.board.shake { animation: shake .5s ease; }
+@keyframes shake {
+  0%, 100% { transform: translate(0, 0); }
+  20% { transform: translate(-6px, 2px); }
+  40% { transform: translate(5px, -3px); }
+  60% { transform: translate(-4px, -2px); }
+  80% { transform: translate(3px, 2px); }
+}
+.reaction.stay { font-size: calc(var(--sq) * .56); }
+
+/* Game over β€” the curtain call */
+
+.gameover {
+  position: fixed;
+  inset: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: color-mix(in srgb, var(--paper) 82%, transparent);
+  z-index: 100;
+}
+.gameover.hidden { display: none; }
+.go-box {
+  background: var(--paper);
+  border: 2px solid var(--ink);
+  box-shadow: 6px 6px 0 var(--line);
+  padding: 34px 44px;
+  text-align: center;
+  max-width: min(92vw, 480px);
+  animation: go-in .3s ease;
+}
+@keyframes go-in { from { transform: translateY(14px); opacity: 0; } }
+.go-banner {
+  font-family: var(--mono);
+  font-size: clamp(24px, 6vw, 40px);
+  letter-spacing: 4px;
+  margin: 0 0 10px;
+  color: var(--ink);
+}
+.go-banner::after { content: "_"; color: var(--accent); animation: blink 1.1s step-end infinite; }
+.go-detail { margin: 0 0 6px; font-size: 18px; }
+.go-meta { color: var(--soft); margin: 0 0 22px; font-size: 14px; }
+.go-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; margin-bottom: 14px; }
+.go-pgn { color: var(--soft); }
+.go-pgn:hover { color: var(--ink); }
+
+/* CRT theme β€” phosphor green on glass, scanlines and all */
+
+[data-theme="crt"] {
+  color-scheme: dark;
+  --paper: #0b0e08;
+  --panel: #0f140b;
+  --ink: #b6f2a3;
+  --soft: #5d8a52;
+  --line: #24381d;
+  --accent: #56d364;
+  --light-sq: #1a2b14;
+  --dark-sq: #0d170a;
+  --sel: #39512a;
+  --check: #e5534b;
+}
+[data-theme="crt"] body { text-shadow: 0 0 7px rgba(86, 211, 100, .3); }
+[data-theme="crt"] body::after {
+  content: "";
+  position: fixed;
+  inset: 0;
+  pointer-events: none;
+  z-index: 9999;
+  background: repeating-linear-gradient(0deg, rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px);
+}
+[data-theme="crt"] .home .logo { text-shadow: 0 0 12px rgba(86, 211, 100, .6); }
+[data-theme="crt"] .dot { background: rgba(182, 242, 163, .3); }
+[data-theme="crt"] .dot.capture { background: none; border-color: rgba(182, 242, 163, .3); }
+[data-theme="crt"] .sq .coord { color: rgba(182, 242, 163, .45); }
+[data-theme="crt"] .sq.dark .coord { color: rgba(182, 242, 163, .55); }
+[data-theme="crt"] .evalbar { border-color: var(--line); }
+[data-theme="crt"] .piece.traditional.w { color: #e8f5e0; }
@@ -1,27 +0,0 @@-// Short synthesized blips, so there are no audio files to ship. Silent until the
-// first move, since browsers only allow audio after a user gesture.
-let ctx = null;
-function tone(freq, ms, gain) {
-    if (!ctx)
-        ctx = new (window.AudioContext || window.webkitAudioContext)();
-    const osc = ctx.createOscillator();
-    const vol = ctx.createGain();
-    osc.type = "triangle";
-    osc.frequency.value = freq;
-    vol.gain.value = gain;
-    osc.connect(vol).connect(ctx.destination);
-    const t = ctx.currentTime;
-    vol.gain.setValueAtTime(gain, t);
-    vol.gain.exponentialRampToValueAtTime(0.0001, t + ms / 1000);
-    osc.start(t);
-    osc.stop(t + ms / 1000);
-}
-export const sound = {
-    move: () => tone(220, 70, 0.05),
-    capture: () => tone(150, 110, 0.07),
-    check: () => tone(440, 130, 0.06),
-    end: () => {
-        tone(330, 160, 0.06);
-        setTimeout(() => tone(247, 220, 0.06), 120);
-    },
-};
@@ -21,6 +21,21 @@ export const sound = {   move: () => tone(220, 70, 0.05),
   capture: () => tone(150, 110, 0.07),
   check: () => tone(440, 130, 0.06),
+  castle: () => {
+    tone(196, 70, 0.05);
+    setTimeout(() => tone(294, 90, 0.05), 70);
+  },
+  promote: () => {
+    tone(523, 80, 0.06);
+    setTimeout(() => tone(659, 80, 0.06), 80);
+    setTimeout(() => tone(784, 150, 0.06), 160);
+  },
+  start: () => {
+    tone(330, 80, 0.06);
+    setTimeout(() => tone(440, 80, 0.06), 90);
+    setTimeout(() => tone(554, 140, 0.06), 180);
+  },
+  tick: () => tone(880, 30, 0.03),
   end: () => {
     tone(330, 160, 0.06);
     setTimeout(() => tone(247, 220, 0.06), 120);
@@ -1,59 +0,0 @@-const DEFAULTS = {
-    pieceSet: "kingdom",
-    sound: true,
-    reactions: false,
-    coords: true,
-    personality: "beginner",
-    side: "w",
-    clock: "none",
-    clockBase: 300,
-    clockInc: 0,
-    threats: false,
-    blunder: false,
-};
-const PREFS_KEY = "emchess.prefs";
-const SAVE_KEY = "emchess.save";
-export function loadPrefs() {
-    try {
-        const raw = localStorage.getItem(PREFS_KEY);
-        if (!raw)
-            return { ...DEFAULTS };
-        return { ...DEFAULTS, ...JSON.parse(raw) };
-    }
-    catch {
-        return { ...DEFAULTS };
-    }
-}
-export function savePrefs(prefs) {
-    try {
-        localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
-    }
-    catch {
-        /* private mode, no storage β€” the game still works, it just won't remember. */
-    }
-}
-export function saveGame(game) {
-    try {
-        localStorage.setItem(SAVE_KEY, JSON.stringify(game));
-    }
-    catch {
-        /* ignore */
-    }
-}
-export function loadGame() {
-    try {
-        const raw = localStorage.getItem(SAVE_KEY);
-        return raw ? JSON.parse(raw) : null;
-    }
-    catch {
-        return null;
-    }
-}
-export function clearGame() {
-    try {
-        localStorage.removeItem(SAVE_KEY);
-    }
-    catch {
-        /* ignore */
-    }
-}
@@ -2,6 +2,7 @@ import type { SetId } from "../board/pieces"; 
 export interface Prefs {
   pieceSet: SetId;
+  theme: "paper" | "crt";
   sound: boolean;
   reactions: boolean;
   coords: boolean;
@@ -16,6 +17,7 @@ } 
 const DEFAULTS: Prefs = {
   pieceSet: "kingdom",
+  theme: "paper",
   sound: true,
   reactions: false,
   coords: true,
@@ -1,14 +0,0 @@-// A position can travel in the URL as ?fen=... so a game or puzzle is shareable
-// without any account or server.
-export function fenFromUrl() {
-    const params = new URLSearchParams(location.search);
-    const fen = params.get("fen");
-    return fen ? decodeURIComponent(fen) : null;
-}
-export function routeFromUrl() {
-    const hash = location.hash.replace(/^#\/?/, "");
-    return hash || "home";
-}
-export function goto(route) {
-    location.hash = `/${route}`;
-}
@@ -10,6 +10,7 @@ "noUnusedParameters": true,     "noFallthroughCasesInSwitch": true,
     "isolatedModules": true,
     "skipLibCheck": true,
+    "noEmit": true,
     "esModuleInterop": true,
     "resolveJsonModule": true,
     "types": []