code.pablomurad.com

emchess

Browser chess with emoji pieces against Stockfish, no server needed

feat: very small fix on mime and wasm
author
Pablo Murad
date
commit
582b0387fa0b9b6f7b51a8a13c4fee17764ffb39
parent
be8e1da8b28b364ae535696c302f3e41d52448c0
changes
8 files +191 −72
@@ -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>`;