@@ -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
@@ -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>
@@ -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) {
@@ -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);
};
}
@@ -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));
@@ -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> {
@@ -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>`;
@@ -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>`;