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
24 Aug 2026, 00:47 -0300
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": []