all repos — emchess @ 582b0387fa0b9b6f7b51a8a13c4fee17764ffb39

feat: very small fix on mime and wasm
Pablo Murad pblmrd@gmail.com
Thu, 30 Jul 2026 03:56:56 -0300
commit

582b0387fa0b9b6f7b51a8a13c4fee17764ffb39

parent

be8e1da8b28b364ae535696c302f3e41d52448c0

M README.mdREADME.md

@@ -2,8 +2,7 @@ EMCHESS

======= A small chess game that runs in the browser. The pieces are emoji. -No accounts, no ads, no server required. Open it and play. -Analytics are handled by Tinylytics (privacy-friendly, no cookies). +No accounts, no ads, no tracking, no server required. Open it and play. DESCRIPTION
M index.htmlindex.html

@@ -9,7 +9,7 @@

<title>Emchess — a quiet game of chess</title> <meta name="description" - content="A small, quiet chess game with emoji pieces. Play a good computer opponent right in your browser — no accounts, no ads, nothing to install." + content="A small, quiet chess game with emoji pieces. Play a good computer opponent right in your browser — no accounts, no ads, no tracking." /> <meta name="author" content="Pablo Murad" /> <meta name="robots" content="index, follow" />

@@ -23,7 +23,7 @@ <meta property="og:site_name" content="Emchess" />

<meta property="og:title" content="Emchess — a quiet game of chess" /> <meta property="og:description" - content="A small, quiet chess game with emoji pieces. No accounts, no ads, nothing to install." + content="A small, quiet chess game with emoji pieces. No accounts, no ads, no tracking." /> <meta property="og:image" content="./og.svg" /> <meta property="og:locale" content="en" />

@@ -40,15 +40,13 @@ "@type": "WebApplication",

"name": "Emchess", "applicationCategory": "GameApplication", "operatingSystem": "Any modern web browser", - "description": "A small, quiet chess game with emoji pieces. Play a good computer opponent in your browser — no accounts, no ads, nothing to install.", + "description": "A small, quiet chess game with emoji pieces. Play a good computer opponent in your browser — no accounts, no ads, no tracking.", "browserRequirements": "Requires JavaScript and WebAssembly.", "isAccessibleForFree": true, "offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }, "author": { "@type": "Person", "name": "Pablo Murad", "email": "pablomurad@pm.me" } } </script> - - <script src="https://tinylytics.app/embed/vv8SZxzqc8zzDe7B4fEr.js" defer></script> </head> <body> <div id="app"></div>
M src/game/clock.jssrc/game/clock.js

@@ -20,7 +20,6 @@ this.onTick = onTick;

this.onFlag = onFlag; this.turn = null; this.last = 0; - this.frame = 0; this.flagged = null; this.tick = () => { if (!this.turn || this.flagged)

@@ -32,12 +31,12 @@ 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.frame = requestAnimationFrame(this.tick); }; this.remaining = { w: tc.base * 1000, b: tc.base * 1000 }; this.incMs = tc.inc * 1000;

@@ -50,7 +49,7 @@ if (!this.enabled)

return; this.turn = side; this.last = performance.now(); - this.tick(); + this.run(); } // The side that just moved gets the increment; the other side's clock starts running. press(mover) {

@@ -60,13 +59,21 @@ 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() { - cancelAnimationFrame(this.frame); + this.stop(); this.turn = null; } stop() { - cancelAnimationFrame(this.frame); + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + run() { + if (!this.timer) + this.timer = setInterval(this.tick, 100); } } export function formatTime(ms) {
M src/game/clock.tssrc/game/clock.ts

@@ -29,7 +29,7 @@ private remaining: Record<Color, number>;

private incMs: number; private turn: Color | null = null; private last = 0; - private frame = 0; + private timer: ReturnType<typeof setInterval> | undefined; flagged: Color | null = null; constructor(

@@ -49,7 +49,7 @@ start(side: Color) {

if (!this.enabled) return; this.turn = side; this.last = performance.now(); - this.tick(); + this.run(); } // The side that just moved gets the increment; the other side's clock starts running.

@@ -59,15 +59,23 @@ 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() { - cancelAnimationFrame(this.frame); + this.stop(); this.turn = null; } stop() { - cancelAnimationFrame(this.frame); + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private run() { + if (!this.timer) this.timer = setInterval(this.tick, 100); } private tick = () => {

@@ -79,12 +87,12 @@ 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.frame = requestAnimationFrame(this.tick); }; }
M src/game/engine.jssrc/game/engine.js

@@ -1,57 +1,89 @@

-const ENGINE_URL = `${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`; -// A thin wrapper around Stockfish running in a Web Worker. Besides talking UCI it -// enforces a minimum "thinking" time, so even when the engine answers instantly the -// opponent still appears to sit and consider the move. +const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href; +const WASM_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.wasm`, location.href).href; +// A tiny worker that fetches the wasm as a plain binary and hands it to Stockfish +// through Module.wasmBinary. That path ignores the response's MIME type, so a server +// that serves .wasm as text/plain (a very common misconfig) can't break the engine. +const BOOTSTRAP = `self.onmessage=function(e){var d=e.data||{};if(!d.__boot)return;` + + `fetch(d.wasmUrl).then(function(r){return r.arrayBuffer();}).then(function(buf){` + + `self.Module={wasmBinary:buf,locateFile:function(p){return p.slice(-5)==='.wasm'?d.wasmUrl:p;}};` + + `try{importScripts(d.jsUrl);postMessage('__ready');}catch(err){postMessage('__err '+err);}` + + `}).catch(function(err){postMessage('__err '+err);});};`; export class Engine { constructor() { this.listeners = []; - this.worker = new Worker(ENGINE_URL); + const bootUrl = URL.createObjectURL(new Blob([BOOTSTRAP], { type: "text/javascript" })); + this.worker = new Worker(bootUrl); 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.handshake(); + this.ready = this.boot(); } send(cmd) { this.worker.postMessage(cmd); } - waitFor(token) { - return new Promise((resolve) => { + 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 handshake() { + async boot() { + this.worker.postMessage({ __boot: true, jsUrl: ENGINE_URL, wasmUrl: WASM_URL }); + await this.waitFor("__ready", 20000); this.send("uci"); - await this.waitFor("uciok"); + await this.waitFor("uciok", 20000); this.send("isready"); - await this.waitFor("readyok"); + await this.waitFor("readyok", 20000); } async configure(p) { - await this.ready; + 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"); + await this.waitFor("readyok", 8000).catch(() => { }); } - // Returns a move in UCI form (e.g. "e2e4"), never before the personality's think - // floor has passed. `legal` lets us occasionally throw a beginner's blunder. + // 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) { - await this.ready; const started = performance.now(); const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); let move; - if (legal.length && Math.random() < p.blunderChance) { - move = legal[Math.floor(Math.random() * legal.length)]; + 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); + move = (await this.search(fen, p)) ?? randomOf(legal); } const elapsed = performance.now() - started; if (elapsed < floor)

@@ -61,6 +93,15 @@ }

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+)/);

@@ -68,11 +109,11 @@ if (pv)

candidates.set(Number(pv[1]), pv[2]); } else if (line.startsWith("bestmove")) { - this.listeners = this.listeners.filter((l) => l !== fn); const best = line.split(" ")[1]; - resolve(best === "(none)" ? null : pick(candidates, best, p.multipv)); + 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));

@@ -82,10 +123,24 @@ });

} // Full-strength evaluation for the coach, from the side-to-move's point of view. async evaluate(fen, movetime = 500) { - await this.ready; + 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+)/);

@@ -101,10 +156,10 @@ }

} } else if (line.startsWith("bestmove")) { - this.listeners = this.listeners.filter((l) => l !== fn); - resolve({ cp, mate }); + 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");

@@ -117,8 +172,6 @@ this.send("quit");

this.worker.terminate(); } } -// With MultiPV the softer opponents don't always grab the single best line; they lean -// toward it but sometimes take the second- or third-best, which feels more human. function pick(candidates, best, multipv) { if (multipv <= 1 || candidates.size <= 1) return best;

@@ -131,6 +184,9 @@ 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));
M src/game/engine.tssrc/game/engine.ts

@@ -1,69 +1,99 @@

import type { Personality } from "./personalities"; -const ENGINE_URL = `${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`; +const ENGINE_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.js`, location.href).href; +const WASM_URL = new URL(`${import.meta.env.BASE_URL}stockfish/stockfish-18-lite-single.wasm`, location.href).href; + +// A tiny worker that fetches the wasm as a plain binary and hands it to Stockfish +// through Module.wasmBinary. That path ignores the response's MIME type, so a server +// that serves .wasm as text/plain (a very common misconfig) can't break the engine. +const BOOTSTRAP = `self.onmessage=function(e){var d=e.data||{};if(!d.__boot)return;` + + `fetch(d.wasmUrl).then(function(r){return r.arrayBuffer();}).then(function(buf){` + + `self.Module={wasmBinary:buf,locateFile:function(p){return p.slice(-5)==='.wasm'?d.wasmUrl:p;}};` + + `try{importScripts(d.jsUrl);postMessage('__ready');}catch(err){postMessage('__err '+err);}` + + `}).catch(function(err){postMessage('__err '+err);});};`; type Line = string; -// A thin wrapper around Stockfish running in a Web Worker. Besides talking UCI it -// enforces a minimum "thinking" time, so even when the engine answers instantly the -// opponent still appears to sit and consider the move. export class Engine { private worker: Worker; private ready: Promise<void>; private listeners: ((line: Line) => void)[] = []; constructor() { - this.worker = new Worker(ENGINE_URL); + const bootUrl = URL.createObjectURL(new Blob([BOOTSTRAP], { type: "text/javascript" })); + this.worker = new Worker(bootUrl); 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.handshake(); + this.ready = this.boot(); } private send(cmd: string) { this.worker.postMessage(cmd); } - private waitFor(token: string): Promise<void> { - return new Promise((resolve) => { + private waitFor(token: string, timeout = 0): Promise<void> { + return new Promise((resolve, reject) => { + let timer: ReturnType<typeof setTimeout> | undefined; const fn = (line: 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); + } }); } - private async handshake() { + private async boot() { + this.worker.postMessage({ __boot: true, jsUrl: ENGINE_URL, wasmUrl: WASM_URL }); + await this.waitFor("__ready", 20000); this.send("uci"); - await this.waitFor("uciok"); + await this.waitFor("uciok", 20000); this.send("isready"); - await this.waitFor("readyok"); + await this.waitFor("readyok", 20000); } async configure(p: Personality) { - await this.ready; + 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"); + await this.waitFor("readyok", 8000).catch(() => {}); } - // Returns a move in UCI form (e.g. "e2e4"), never before the personality's think - // floor has passed. `legal` lets us occasionally throw a beginner's blunder. + // 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: string, p: Personality, legal: string[]): Promise<string | null> { - await this.ready; const started = performance.now(); const floor = p.think[0] + Math.random() * (p.think[1] - p.think[0]); let move: string | null; - if (legal.length && Math.random() < p.blunderChance) { - move = legal[Math.floor(Math.random() * legal.length)]; + 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); + move = (await this.search(fen, p)) ?? randomOf(legal); } const elapsed = performance.now() - started;

@@ -74,17 +104,24 @@

private search(fen: string, p: Personality): Promise<string | null> { return new Promise((resolve) => { const candidates = new Map<number, string>(); - + let done = false; + const finish = (m: string | null) => { + if (done) return; + done = true; + clearTimeout(timer); + this.listeners = this.listeners.filter((l) => l !== fn); + resolve(m); + }; const fn = (line: 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")) { - this.listeners = this.listeners.filter((l) => l !== fn); const best = line.split(" ")[1]; - resolve(best === "(none)" ? null : pick(candidates, best, p.multipv)); + 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);

@@ -96,10 +133,22 @@ }

// Full-strength evaluation for the coach, from the side-to-move's point of view. async evaluate(fen: string, movetime = 500): Promise<{ cp: number | null; mate: number | null }> { - await this.ready; + try { + await this.ready; + } catch { + return { cp: null, mate: null }; + } return new Promise((resolve) => { let cp: number | null = null; let mate: number | null = 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: Line) => { if (line.startsWith("info")) { const s = line.match(/score (cp|mate) (-?\d+)/);

@@ -108,10 +157,10 @@ if (s[1] === "cp") { cp = Number(s[2]); mate = null; }

else { mate = Number(s[2]); cp = null; } } } else if (line.startsWith("bestmove")) { - this.listeners = this.listeners.filter((l) => l !== fn); - resolve({ cp, mate }); + 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");

@@ -126,8 +175,6 @@ this.worker.terminate();

} } -// With MultiPV the softer opponents don't always grab the single best line; they lean -// toward it but sometimes take the second- or third-best, which feels more human. function pick(candidates: Map<number, string>, best: string, multipv: number): string { if (multipv <= 1 || candidates.size <= 1) return best; const weights = [0.62, 0.26, 0.09, 0.03];

@@ -138,6 +185,10 @@ acc += weights[i - 1] ?? 0.02;

if (roll < acc && candidates.has(i)) return candidates.get(i)!; } return best; +} + +function randomOf(moves: string[]): string | null { + return moves.length ? moves[Math.floor(Math.random() * moves.length)] : null; } function sleep(ms: number): Promise<void> {
M src/main.jssrc/main.js

@@ -179,7 +179,7 @@ <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> - <a href="mailto:pablomurad@pm.me">pablomurad@pm.me</a> + pablomurad[at]pm[dot]me </div> <p class="kill">Kill the king — MMXXVI</p> </div>`;
M src/main.tssrc/main.ts

@@ -198,7 +198,7 @@ <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> - <a href="mailto:pablomurad@pm.me">pablomurad@pm.me</a> + pablomurad[at]pm[dot]me </div> <p class="kill">Kill the king — MMXXVI</p> </div>`;