Case studyPersonal project2026

ChessLens

Pulls your real chess.com games, runs them through Stockfish, and drops you back on the board at the exact move you lost the game. Two engines, because the analysis and the hint are different problems.

ChessLens game analysis screen with accuracy score and blunder detection

Engine analysis on chess.com sits behind Diamond, at ninety-nine dollars a year. That is a reasonable price for a serious player and a bad deal for the band I am in, somewhere around 1000 to 1600, where the mistakes are not subtle and you do not need a grandmaster's tooling to find them. You need someone to point at the move where the game turned and let you play it again.

So ChessLens fetches your games from the chess.com public API, analyses them with Stockfish, and opens the board at your worst error with the clock still counting down from the real game.

Two engines, on purpose

The obvious design is one Stockfish. The right one turned out to be two, because the two jobs have opposite constraints.

The analysis pass runs a native Stockfish binary on the server through python-chess, at depth 12, over every move of every game in the month. That is real CPU work, and in an async web app it is the classic way to freeze everything: a blocking subprocess sitting in the event loop stalls every other request. It runs through an executor, off the loop.

The hint inside the replay is the opposite. You are staring at one position and want an answer now, and a round trip to the server to spawn an engine process is both slow and a denial-of-service invitation. That one runs Stockfish compiled to WebAssembly in a Web Worker, in your own browser, on your own CPU. It loads lazily on the first hint rather than on page load, since most visitors never ask for one, and there is no reason to pull an engine down the wire for them.

Hints escalate rather than dumping the answer: a heuristic nudge first, then the engine's evaluation, then the exact move. Handing over the move immediately teaches nothing.

Accuracy is not average centipawn loss

The first version scored accuracy on raw centipawn loss, which is the intuitive metric and the wrong one.

Centipawn loss treats every hundred points as equal, and they are not. Dropping a hundred centipawns in a dead-level position can be the difference between drawing and losing. Dropping the same hundred when you are already up a rook changes nothing about the outcome, and punishing it produces a score that disagrees with the player's own experience of the game.

So the eval is converted to a win probability first, through a logistic curve, and accuracy is computed from the average win probability lost rather than the average centipawns lost. The same blunder now costs what it actually cost you: a lot in a close game, almost nothing in a won one.

The report writes itself, then a model writes it up

Before any prose, the pipeline extracts patterns from the month statistically: timeout rate, worst opening by win rate, average accuracy, blunders per game. Each has a threshold, and they resolve to a single main weakness, or to balanced when nothing crosses a line.

Only then does an LLM turn that into a paragraph. It is given the conclusion, not the data, so it cannot invent a trend that the numbers do not support. The model is reached through LiteLLM rather than a provider SDK, which is the same instinct I keep applying: the thing that generates the text should be swappable without touching the thing that decides what is true.

Deploying it broke in four ordinary ways

None of these are clever. They are the tax on shipping to a real box.

The image bundled its own reverse proxy while the host already ran nginx, so two servers argued over port 443 until I deleted mine and let the host terminate TLS. Migrations ran both in the container command and at application startup, and two Alembic processes racing for the same lock deadlocked on boot, which presents as a container that starts and then simply does nothing. Debian installs Stockfish to /usr/games, which is not on the PATH in a slim image, so the engine was missing in production and present everywhere I had tested. And the slim image ships without curl, which I only noticed because of the next thing.

The curl subprocess I am not proud of

There is a route that hands a position off to Lichess for a proper board. It should be an HTTP POST from the same async client the rest of the app uses. Instead it shells out to curl.

The reason is that LiteLLM patches the HTTP stack at import time, and the redirect handling I needed stopped behaving once it was in the process. I could not use my HTTP client because of my LLM library. The honest fix is to isolate the LLM call so it stops mutating global state for everyone else. The shipped fix is a subprocess, which works, and which I would flag in review if someone else wrote it.

What is not done

The per-game analysis is recomputed on every request instead of being stored, which is fine for the traffic it has and is the first thing that falls over if that changes. There is no rate limiting or backoff against the chess.com API, which is friendly right up until it is not. An earlier JWT authentication layer is still in the tree, unmounted and superseded by cookie identity, and dead code that looks alive is worse than no code.

The gap that actually matters is not technical. It works, it is deployed, and no stranger has used it yet. Everything above is engineering I can defend, and none of it is evidence that anyone wants the product. That is the next thing to find out.

Stack

PythonFastAPITyperStockfishpython-chessPostgreSQLSQLAlchemyAlembicLiteLLMDocker