Sponsored Content

DEV Community

Cover image for Four JavaScript problems I hit writing a chess engine for the browser
Taimoor Bamazai
Taimoor Bamazai

Posted on AI-assisted

Four JavaScript problems I hit writing a chess engine for the browser

I wrote a chess engine in TypeScript that runs entirely in the browser. No server, no WASM Stockfish, no dependencies at runtime. Paste a position, get a move back in about a second and a half.

Most of the chess parts were the easy bit β€” the algorithms are fifty years old and well documented. What took the time were four problems that only exist because the target is JavaScript in a browser tab. These are the ones I would want to have read about first.

1. Zobrist hashing wants 64 bits. JavaScript gives you 32.

A transposition table is how a chess engine avoids re-searching a position it has already seen. You need a hash of the position, and the standard is Zobrist hashing: XOR together a random 64-bit key for every piece-square combination, plus keys for side-to-move, castling rights and the en-passant file.

Sixty-four bits. JavaScript's bitwise operators coerce to 32-bit signed integers. 1 << 31 is negative and 1 << 32 is 1.

The obvious answer is BigInt. Do not use BigInt. This code runs on the hottest path in the entire program, millions of times per search, and BigInt allocates. It was not close.

What works is keeping the key as two 32-bit halves and XORing both:

const ZP_LO = new Int32Array(12 * 128);
const ZP_HI = new Int32Array(12 * 128);
for (let i = 0; i < 12 * 128; i++) { ZP_LO[i] = rnd32(); ZP_HI[i] = rnd32(); }

// ...then per piece on the board:
lo ^= ZP_LO[k];
hi ^= ZP_HI[k];
Enter fullscreen mode Exit fullscreen mode

A collision now needs both halves to agree, which puts you back in the same 2⁻⁢⁴ regime real engines accept. Two Int32Arrays and two XORs per update, no allocation.

I also seed the key generator myself rather than using Math.random():

let rndState = 0x1e57ab1e | 0;
function rnd32(): number {
  rndState = (rndState + 0x9e3779b9) | 0;
  let z = rndState;
  z ^= z >>> 16; z = Math.imul(z, 0x21f0aaad);
  z ^= z >>> 15; z = Math.imul(z, 0x735a2d97);
  z ^= z >>> 15;
  return z | 0;
}
Enter fullscreen mode Exit fullscreen mode

Math.imul is there because * on two large integers goes through doubles and loses the low bits. The fixed seed matters for a different reason: hashes are identical run to run, so a failing test fails the same way twice.

2. You cannot cancel a synchronous search with a message

The search runs in a Web Worker so the page thread stays responsive. Fine. Then the user changes the position mid-search and you need to stop.

The instinct is to post a stop message and have the worker check for it. That does not work, and it is worth being precise about why: the search is synchronous. It occupies the worker's only thread from start to finish. A posted message sits in the event queue and cannot be observed until the search returns β€” at which point there is nothing left to cancel.

You could thread a deadline through every node and poll a SharedArrayBuffer, but SharedArrayBuffer needs cross-origin isolation headers, which is a real deployment constraint to accept for a stop button.

So there is deliberately no stop message in my protocol. Cancellation terminates the worker:

stop(): void {
  if (!this.job) return;
  this.teardownWorker();
  this.finishStopped();
}
Enter fullscreen mode Exit fullscreen mode

A fresh worker spawns on the next request. It is immediate, it cannot leak, because all search state lives inside the worker that just died, and starting a new analysis while one is running supersedes the old one the same way.

Worker startup cost is real but small, and it is paid on a user action rather than in the search loop.

3. Alpha-beta gives you bounds, not scores

This one cost me the most, and it is a correctness bug rather than a performance one.

I wanted to show the top four moves with an evaluation for each. Alpha-beta prunes by proving a move is worse than something already found β€” it does not compute how much worse. The score attached to a rejected move is a fail-low bound. Displaying it as an evaluation is displaying a number that means "at most this", as if it meant "this".

The symptom is subtle and horrible: the best move is always right, and the runners-up are quietly wrong. Everything looks fine.

The fix is to re-search the root once per line, excluding the moves already chosen:

search root                       -> best move, EXACT score
search root, excluding best       -> 2nd line, EXACT score
search root, excluding best + 2nd -> 3rd line, EXACT score
Enter fullscreen mode Exit fullscreen mode

It costs roughly a full search per extra line. It is worth it, because everything downstream β€” the explanation layer that says why a move was rejected β€” depends on those numbers being real.

One edge case to handle: if the exclusion list eliminates every legal move, that is "nothing left to search", not checkmate. Reporting a game-over verdict the position does not support is a bug I shipped and had to fix.

4. Move ordering beats depth, and perft is the only thing keeping you honest

Two lessons that are not JavaScript-specific but that I underrated.

Ordering. A well-ordered depth-7 search plays better than a badly-ordered depth-9. The order that mattered: hash move first, then captures by MVV-LVA (most valuable victim, cheapest attacker), then killer moves, then history heuristic. Getting that right was a far bigger win than any micro-optimisation I tried.

Perft. Before trusting any of it, count leaf nodes at fixed depth from known positions and compare against published reference values:

startpos  depth 4  ->     197,281
kiwipete  depth 3  ->      97,862
position3 depth 4  ->      43,238
Enter fullscreen mode Exit fullscreen mode

These numbers are unforgiving. One bug in castling rights, en-passant legality, promotion generation or pin detection and the count is wrong. Not approximately wrong β€” wrong. It is the rare test that cannot be fudged, and every one of my move-generation bugs was caught by it rather than by playing.

If you write a move generator and do not run perft, you do not have a move generator. You have something that looks like one.

Where it ended up

Fixed 1.5-second budget, reaching depth 6 after roughly 330,000 positions on a mid-range machine. Forced mates resolve almost immediately β€” Scholar's mate comes back at depth 1 in about 15ms. Doubling the thinking time buys roughly one extra ply.

I also measured the browser against the same engine running under Node, expecting the browser to be meaningfully slower. It was not: the gap was smaller than run-to-run variance. For a search this size, the JIT does fine.

The evaluation is split into nine named terms β€” material, piece-square tables, mobility, pawn structure, bishop pair, rooks, king safety, tempo, threats β€” which is mostly so the thing can explain itself in words rather than return a number.

You can put the board in your own page

The board, the rules engine and the pieces are all first-party, so I made the board embeddable. One iframe, no account, no API key, nothing to install:

<iframe
  src="https://chessloupe.com/embed/board/?fen=r3k3/8/8/1N6/8/8/8/4K3%20w%20-%20-%200%201"
  width="480" height="520" style="border:0" loading="lazy"
  title="A knight fork"></iframe>
Enter fullscreen mode Exit fullscreen mode

It takes a FEN, flips, turns interactivity off for a static diagram, and posts a message to the parent window on every move if you want to build a puzzle around it. The attribution under the board comes off with brand=0 and nothing breaks. Parameters and examples are here.

The pieces are original SVGs rather than the usual Cburnett set, which is CC-BY-SA β€” worth knowing if you are embedding chess graphics in something you do not want to put a share-alike obligation on.


I build ChessLoupe, a free set of browser chess tools. The engine described here is the one it runs on.

Top comments (0)