Case study · Engine
Bongaclang
A chess engine written from scratch that plays 24/7 on Lichess — for free — and opens with the Bongcloud on purpose.
- Year
- 2026
- Role
- Solo — the search, the evaluation, the deployment, and the browser port.
Problem
Write a chess engine from scratch — no engine library, just the rules — strong enough to hold its own as a Lichess bot. Then keep it running 24/7 without paying a cent for a server, and get a taste of it playing in a browser tab so anyone can try it with no round-trip.
“From scratch” is the whole point: I wanted to understand every centipawn the engine spends, not glue an existing engine to an API. The Bongcloud opening (1. e4 … 2. Ke2) is a deliberate personality tell — a serious engine wearing a silly hat.
Constraints
- No chess-engine dependency — move generation and legality are mine (chess.js only stands in for the move generator in the browser port). All search and evaluation is hand-written.
- A per-move time budget of a few seconds, like a real blitz game — and every ply has to be earned inside it.
- It has to run for free, forever: a tiny Always-Free cloud box with ~1GB of RAM and a sliver of a CPU core, and it must never fall over.
- The browser version has to run in a tab: no native threads, no server, and it must never freeze the UI.
Architecture
The core is a time-budgeted, iterative-deepening negamax/PVS search: it searches depth 1, then 2, then 3, always keeping the best move from the last fully-completed depth, and stops the moment the clock runs out. A transposition table carries cutoffs and hash-move ordering across iterations, and aspiration windows re-use the last iteration's score to search a narrow band around it — widening only on a fail.
On top of that is the usual pile of pruning tricks — null-move, late-move reductions, a few futility cutoffs — that let it skip work it can prove it doesn't need, all fed by ordinary move ordering (killers, history, MVV-LVA). None of it is novel; it's the standard toolkit, applied carefully and only kept when it actually helped.
- Order movesTT · captures · killers · history
- Scoutnull-window α-β
- Re-searchonly if it beats α
- Leavesquiescence → PST eval
At the leaves it does a little captures-only quiescence search so it doesn't misjudge a position mid-trade, then scores what's left with a fairly ordinary evaluation — material, piece-squares, and some pawn-structure and king-safety terms — computed straight off bitboards to keep it fast enough to be worth running.
In the browser, none of that heavy machinery ships. A lighter TypeScript port — the same search skeleton but a simpler, older evaluation — runs inside a dedicated Web Worker spun up from a module URL. The board UI posts a move history to the worker and gets the chosen move back over an id-keyed message protocol, so the search can think without ever janking the page. It plays a recognisable taste of Bongaclang, not the full Lichess bot.
Decisions & tradeoffs
The load-bearing decision was to make the search cheap enough to run for free. Bongaclang lives 24/7 on an Oracle Cloud Always-Free AMD VM — about 1GB of RAM — under PyPy, whose JIT is what makes pure-Python search fast enough to be worth playing. systemd keeps the process alive and restarts it if it dies; a swapfile keeps PyPy from being OOM-killed on the tiny box. The whole thing costs nothing and has stayed up.
Honesty beat: that free box currently gives it roughly one-eighth of an AMD core, and that genuinely caps its strength — it would play meaningfully better with more CPU. I'd rather say that out loud than quietly imply the rating is the ceiling of the engine.
The second decision was methodological: nothing ships without evidence. Every engine change — each pruning term, each evaluation tweak — was validated by SPRT self-play through a phased roadmap, running the new version against the old one until the test cleared or failed. A change that felt clever but didn't pass didn't make it in. That's the difference between an engine that gets stronger and one that just gets more complicated.
For the browser port, the biggest decision was to stop using chess.js's public move API. The verbose path recomputes a SAN string for every move, and generating one SAN runs a full legal move-generation — so the public API is O(n²) per node. The port instead reaches for chess.js's internal move functions, which skip SAN and re-validation entirely.
// chess.js internal API (not in the public typings)
interface ChessInternals {
_moves(opts?: { legal?: boolean }): InternalMove[];
_makeMove(move: InternalMove): void;
_undoMove(): InternalMove;
}
const internals = (game: Chess) => game as unknown as ChessInternals;
const genLegal = (game: Chess) => internals(game)._moves({ legal: true });
const makeMove = (game: Chess, m: InternalMove) => internals(game)._makeMove(m);
const undoMove = (game: Chess) => internals(game)._undoMove();A deliberate, documented double-cast — the internal API isn't in the public typings, but it's the difference between a toy and a browser port that reaches real depth.
Public moves({verbose}) recomputes SAN per move (each SAN is a full legal move-gen) → O(n²) per node. The internal _moves/_makeMove/_undoMove path skips SAN + re-validation.
PVS and the transposition table earn a footnote of caution: the table must never cut at the root (the search has to return an actual move there) and never cut on mate scores, because distance-to-mate isn't ply-adjusted in the table. Getting that wrong makes an engine confidently throw away won positions.
if (!isRoot && tt.depth >= depth && Math.abs(tt.score) < MATE_THRESHOLD) {
if (tt.flag === EXACT) return { score: tt.score, move: tt.move };
if (tt.flag === LOWER && tt.score >= beta) return { score: tt.score, move: tt.move };
if (tt.flag === UPPER && tt.score <= alpha) return { score: tt.score, move: tt.move };
}What didn't work (and the honest deviations)
Not every clever idea survived SPRT. The value of gating everything on self-play is that it kills your darlings for you: terms that looked principled on paper lost games and got cut. The roadmap is as much a graveyard of rejected ideas as a list of wins, and that's the point — the engine only keeps what earns its place.
The browser port is deliberately not the Lichess bot. Porting the full direct-bitboard evaluation and the whole pruning suite to TypeScript, and paying for them inside a Web Worker on a phone, wasn't worth it for a play-a-taste demo. So the port runs a simpler, older evaluation:
- The in-browser evaluation is an earlier, lighter version — piece-square tables without the tapered king PST, the pawn-structure cache, or the endgame mop-up terms the Python engine now uses.
- It does not reproduce the Python engine move-for-move; it's a faithful taste of how Bongaclang plays, not a mirror of the bot you'd meet on Lichess.
- The transposition table is per-move, rebuilt each turn — the “across iterations” win is scoped to a single move's search, not the whole game.
This used to claim move-for-move parity between the browser and the bot. That was true of an older, pre-pruning version and isn't anymore — the real engine has moved on. The honest framing is a lighter port you can play, and a stronger bot living on Lichess.
Outcome
Bongaclang plays live on Lichess 24/7, for free, on a sliver of a CPU on a free cloud box. It's a hobby engine, honestly — it isn't going to trouble Stockfish — but it's mine, built from the rules up, and it holds its own. A lighter TypeScript port lets you play a taste of it in a browser tab on this very site. It still opens with the Bongcloud.
My role
Everything — the search, the evaluation, the free PyPy-on-Oracle deployment, the self-play testing that gated every change, and the lighter TypeScript port with the Web Worker that keeps the search off the UI thread.