Anyone who plays chess knows the familiar frustration of post-game engine review.
You play a move that feels completely natural and intuitive. You turn on Stockfish, and the evaluation bar drops off a cliff. The engine flags your move as a blunder, pointing to a long, precise line that few humans would ever find over the board.
Stockfish is built to find the objectively strongest move. But when you are reviewing your mistakes, objective strength only gets you halfway there. You also want to know: what would another human actually play here?
As a software developer who plays chess, I wanted post-puzzle analysis to reflect how humans actually play. Here is how I integrated Maia 2 to run client-side in the browser using ONNX Runtime Web and WebAssembly, with no inference server.
The Spark: Dissecting My Own Blunders
The idea for this came directly from my own training. I keep a blunder log where I write up post-mortems of puzzles I failed.
Writing those breakdowns made one thing clear: I did not just miss the right move; I got seduced by a tempting wrong one.
When I asked myself why, these were some of the possible explanations I came up with:
- The trap of an appealing motif: Locking onto a tempting discovered attack and stopping the search for alternatives because the idea looks so good at first glance.
- Looking for a ghost attack: Getting distracted by vague attacking ideas against the opposing king when there is no real attack in the position.
- Relying on opening habits instead of concrete tactics: Looking only for moves that feel natural and developing, which hides an awkward-looking retreat that solves the concrete tactical problem.
- The desire for forced continuations: Dismissing a winning check simply because the opposing king has three legal squares instead of a single, forced reply.
- The sniper bishop bias: Treating bishops like snipers that work from a distance, which makes you reluctant to bring them into the middle of the action.
Every puzzle in my app comes from a real game, so the tactic only exists because someone made a mistake on the previous turn. When you fail to find the solution, it is often because the winning move feels counter-intuitive, while a superficially appealing alternative catches your eye. Stockfish can show you why that tempting move fails, but it cannot tell you how likely a player at your rating level is to play that same tempting move.
Stockfish vs. Maia: Finding the Right Partner
Stockfish searches millions of positions to find the best theoretical move. Maia, developed by researchers at the University of Toronto's Computational Social Science Lab (CSSLab) together with collaborators from Cornell and Microsoft Research, takes the opposite approach.
Maia is trained on millions of real games played by human players on Lichess across different rating levels. Instead of trying to win, its main goal is to predict the move a human of a specific rating is most likely to play.
Once I decided to bring Maia's predictions into my puzzle review workflow, I had to choose which generation of the model to use. The researchers have released multiple iterations over the years, and two practical factors guided the choice:
- A Single Unified Model: The original Maia (Maia 1) shipped a separate model for each 100-point rating level (one model for 1100, another for 1200, and so on). Maia 2 unified this into a single neural network that takes the player's and the opponent's ratings as inputs, so one download covers every rating level.
- Licensing: The newer Maia 3 is licensed under AGPL-3.0, a strong copyleft license. Maia 2 is distributed under the permissive MIT License, which makes it simple to package in a client-side web application.
The Goal: No Servers, No GPU Bills, Pure WebAssembly
Running machine learning models often means standing up a Python backend with GPU instances. For an independent developer, that means recurring cloud bills, scaling concerns, latency on every board update, and privacy considerations around sending user game data across the wire.
I wanted the entire experience to run locally inside the user's browser:
- Zero backend server costs.
- No network round-trip for each evaluation.
- No repeated model downloads.
Here is the setup that made it possible.
1. Shrinking the Model to 22.7 MB
Maia 2 stored in 32-bit floats is far too large to ask someone on a mobile connection to download just to review a chess puzzle.
To make it browser-friendly, the model was converted to ONNX and quantized to 8-bit:
- Convolution weights are stored as INT8 with FP16 scales and dequantized back to FP32 inside the graph.
- Fully connected (linear) layers use ONNX Runtime's dynamic 8-bit quantization.
This brings the file down to 22.7 MB (maia2-rapid.onnx).
2. Caching in IndexedDB (Download Once)
To avoid downloading the model on every visit, the worker stores the raw binary in the browser's IndexedDB:
const MODEL_VERSION = 'maia2-rapid-mobile-v1';
const DB_NAME = 'ChessKineticsMaia2';
const STORE_NAME = 'models';
const MODEL_KEY = 'maia2-rapid-onnx';
async function getCachedModelBuffer() {
const db = await openDB();
if (!db) return null;
return new Promise((resolve) => {
const tx = db.transaction([STORE_NAME], 'readonly');
const req = tx.objectStore(STORE_NAME).get(MODEL_KEY);
req.onerror = () => resolve(null);
req.onsuccess = async () => {
const entry = req.result;
if (!entry || entry.version !== MODEL_VERSION || !entry.data) {
resolve(null);
return;
}
try {
const buf = entry.data instanceof Blob
? await entry.data.arrayBuffer()
: entry.data;
resolve(buf);
} catch (_) {
resolve(null);
}
};
});
}
On the first session, the worker fetches the model and writes it to IndexedDB. On later visits, the model is read from device storage instead of the network (unless the browser has cleared the site's storage). Bumping MODEL_VERSION invalidates the cached copy when a new model ships.
3. Offloading Inference to a Web Worker
Running neural network inference on the main JavaScript thread can make the UI stutter and drop frames.
All ONNX Runtime Web operations run inside a dedicated Web Worker (maia-worker.js). The worker runs single-threaded WebAssembly:
importScripts('/maia2/ort.wasm.min.js');
const ORT = self.ort;
ORT.env.wasm.wasmPaths = '/maia2/';
ORT.env.wasm.numThreads = 1;
4. Feeding the Board to the Neural Net
When a position needs evaluation, the frontend sends the FEN string, the legal moves, and the target Elo rating to the worker.
- Board Mirroring: Maia 2 expects the side to move to be White. If it is Black's turn, the worker mirrors the FEN: flipping the ranks, swapping piece colors, and adjusting castling rights and the en passant square.
-
Tensor Encoding: The position is converted into an 18-channel float array of size
[1, 18, 8, 8]:- 12 channels for piece types (P, N, B, R, Q, K for both sides).
- 1 channel for the active turn.
- 4 channels for castling rights.
- 1 channel for en passant squares.
- Elo Bucketing: The target rating is mapped into one of 11 discrete categories (from category 0 for under 1100 up to category 10 for 2000+).
The network produces raw logits for 1880 canonical moves. The worker filters out illegal moves, runs a masked Softmax over the remaining legal candidate moves, mirrors Black's move coordinates back to normal, and posts the top candidate moves back to the UI.
5. Calibrating to Puzzle Difficulty
In chess apps, puzzle ratings and playing ratings are measured on different scales, so a puzzle rating cannot be passed to Maia as-is.
As a heuristic, the app subtracts 350 points from the active puzzle wave rating and clamps the result to the 1000–2000 range:
object Model:
private val waveToMaiaOffset = 350
private val minMaiaElo = 1000
private val maxMaiaElo = 2000
def maiaEloFor(waveRating: Int): Int =
(waveRating - waveToMaiaOffset).max(minMaiaElo).min(maxMaiaElo)
A wave target of 1850 evaluates the position from the perspective of a 1500 player. The UI displays the human candidate moves and their percentage likelihood directly under the board:
Seeing those numbers shows whether your tempting mistake was an obscure slip or a common reflex among players at your level.
Final Thoughts
Building chess tools is one of the most rewarding ways to tinker as a developer. Stockfish remains the gold standard for calculation truth, but adding Maia provides realistic context on typical human candidate moves and rating-based tendencies.
I built this integration for my web app, ChessKinetics, where it runs as part of the post-puzzle review. But the architectural pattern applies to other web projects too: with ONNX Runtime Web and WebAssembly, a model of this size can run right in the user's browser, without a GPU server behind it.












