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, 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"; import { sound } from "./util/sound"; import { loadPrefs, savePrefs, saveGame, clearGame } from "./util/storage"; import { goto } from "./util/url"; type Promo = "q" | "r" | "b" | "n"; const app = document.querySelector("#app")!; let prefs = loadPrefs(); let engine: Engine | null = null; let liveBoard: Board | null = null; const PIECE_VALUE: Record = { p: 1, n: 3, b: 3, r: 5, q: 9 }; const START_COUNT: Record = { 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( human: Color, personality: Personality, tc: TimeControl, text: string ): Promise { 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; } function parseInitial(text: string): { fen?: string; pgn?: string } | null | "invalid" { 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 { private game: Game; private board!: Board; 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; private beforeHuman: Game | null = null; 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 = Promise.resolve(); private evalToken = 0; private lastTickSec = -1; constructor( private human: Color, private personality: Personality, private tc: TimeControl, initial: { fen?: string; pgn?: string } | null ) { 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 glyphs = getSet(prefs.pieceSet).glyphs; const clockHtml = (c: Color) => this.clock.enabled ? `--` : ""; app.innerHTML = `

${this.personality.face} ${this.personality.name}

${this.personality.face} ${this.personality.name} ${clockHtml(opp)}
${glyphs.k} you · ${this.human === "w" ? "white" : "black"} ${clockHtml(this.human)}
`; 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 as Promo), }); liveBoard = this.board; this.board.setPosition(this.game.board()); this.wireControls(); 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.outcome().over) this.showGameOver(); else if (this.game.turn !== this.human) void this.aiMove().finally(() => this.refreshEval()); else void this.refreshEval(); } private 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 as HTMLButtonElement, "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; } private canMove(): boolean { return ( !this.thinking && !this.pendingBlunder && !this.timeLoss && this.game.turn === this.human && !this.game.outcome().over ); } private onHumanMove(from: Square, to: Square, promo?: 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); } private commitHuman(from: Square, to: Square, promo?: 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.board.clearArrow(); this.afterMove(move); if (!this.game.outcome().over && !this.timeLoss) { void this.aiMove().finally(() => this.refreshEval()); } } private 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; app.querySelector(".hud.opp")?.classList.remove("thinking"); if (!uci || this.timeLoss) return this.update(); const move = this.game.playUci(uci); if (move) { this.board.setArrow(move.from as Square, move.to as Square); this.afterMove(move); } else this.update(); } private afterMove(move: 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(); 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; this.setCoach(""); this.board.setPosition(this.game.board()); this.refreshCheck(); this.refreshThreats(); this.update(); this.persist(); void this.refreshEval(); } private playSound(move: Move) { if (!prefs.sound) return; 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(); } private showReactions(move: Move) { if (!prefs.reactions) return; if (move.flags.includes("p")) this.board.react(move.to as Square, "✨"); if (this.game.inCheck()) { const king = this.game.kingSquare(this.game.turn); if (king) this.board.react(king, "❗"); } } private refreshCheck() { this.board.setCheck(this.game.inCheck() ? this.game.kingSquare(this.game.turn) : null); } private refreshThreats() { 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("#evalfill"); const num = app.querySelector("#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 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; this.setCoach(explainPosition(this.game, this.human, cpH, mateH)); } private 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."); } private askBlunder(name: string, square: Square) { this.setCoach(""); const coach = app.querySelector("#coach")!; coach.innerHTML = `This move leaves your ${name} on ${square} for the taking.
`; 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 = ""; }; } private setCoach(text: string) { const coach = app.querySelector("#coach"); if (coach) coach.textContent = text; } 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) { 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); 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 { return !this.timeLoss && !this.game.outcome().over; } private onFlag = (side: Color) => { this.timeLoss = side; this.clock.stop(); if (prefs.sound) sound.end(); this.update(); this.showGameOver(); }; private showThinking() { const status = app.querySelector("#status"); if (status) status.innerHTML = `${this.personality.face} is thinking…`; app.querySelector(".hud.opp")?.classList.add("thinking"); } private update() { this.renderStatus(); this.renderMoves(); this.renderMaterial(); const undo = app.querySelector("#undo"); if (undo && !this.clock.enabled) undo.disabled = this.game.history().length === 0; } private 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 = ``; 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> = { 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("#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 += `${set.glyphs[t]}`; } el.innerHTML = html; } const mat = app.querySelector("#mat-" + c); if (mat) { const diff = c === "w" ? valW - valB : valB - valW; mat.textContent = diff > 0 ? "+" + diff : ""; } } } private 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 += `
  • ${no}.${white}${black}
  • `; } 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("#gameover")!; box.innerHTML = `
    ${banner}

    ${detail}

    ${moves} moves · ${this.personality.face} ${this.personality.name}

    `; box.querySelector("#rematch")!.onclick = () => { this.clock.stop(); clearGame(); const next: Color = this.human === "w" ? "b" : "w"; void startGame(next, this.personality, this.tc, ""); }; box.querySelector("#gonew")!.onclick = () => { this.clock.stop(); clearGame(); goto("play"); }; box.querySelector("#gomenu")!.onclick = () => { this.clock.stop(); clearGame(); goto("home"); }; box.querySelector("#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("#gameover"); if (box) { box.classList.add("hidden"); box.innerHTML = ""; } } private 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: HTMLButtonElement, on: string, off: string) { btn.textContent = on; setTimeout(() => (btn.textContent = off), 1200); } function describeOutcome( outcome: Extract, { over: true }>, human: Color ): string { if (outcome.reason === "checkmate") { return outcome.winner === human ? "Checkmate. You win." : "Checkmate. You lose."; } const draws: Record = { 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."; }