The launch post for Gunnie's Gambit ended with a plan: a review mode, where the engine goes through a finished game and marks the inaccuracies, mistakes and blunders. It is live now at chess.gunnie.nl. This post is about how it judges a move. It is also about the 36 issues a static analyser found after the work had been declared finished.
What it does
When a game against the computer ends, or for any game in Analyse, choose Review game. A PGN pasted from another site works too.
Every move can get a verdict: inaccuracy (?!), mistake (?) or blunder (??). For a marked move, an arrow shows the better one.
A graph shows how the game swung, with the marked moves on it. Click it to jump to that point.
Each side gets an accuracy percentage and a count of each kind of mistake.
Step through with the buttons, the arrow keys or the move list, or jump straight to the next mistake.
Try it on Scholar's Mate: 1.e4 e5 2.Bc4 Nc6 3.Qh5 Nf6 4.Qxf7#. The review marks 3.Qh5 as an inaccuracy and prefers Nf3. It marks 3...Nf6 as a blunder, with g6 as the move that held. White ends on 92% accuracy, Black on 65%.
Lost winning chances, not lost pawns
The obvious way to judge a move is by how far the evaluation drops. Lose two pawns' worth and call it a blunder. That treats every position the same, and they are not the same. Two pawns thrown away at plus eight still wins. Two pawns thrown away in an equal position usually loses.
So the review first turns every evaluation into winning chances, on a scale from -1 (lost) to 1 (won). It uses the curve Lichess fitted to real games:
chances = 2 / (1 + exp(-0.00368208 * centipawns)) - 1The centipawns are capped at 1000 either way. A move is an inaccuracy when it costs 0.1 on that scale, a mistake at 0.2 and a blunder at 0.3. Those are Lichess's thresholds too, so a ?? here means roughly what it means there.
Mates need their own rules, because the curve treats mate in 2 and mate in 20 alike. Letting a forced mate slip, or walking into one, is judged by how good the position still is afterwards. Those rules also come from Lichess.
One more rule: when you play the engine's own first choice, the move is never marked. A search is not perfectly stable, and without that rule the best move could get flagged by noise.
Accuracy per move uses Lichess's formula as well. For the whole game Lichess weights the moves in a way I did not copy, so the percentages come out close to Lichess's but not identical.
Nodes, not seconds
Every position gets one search of 400,000 nodes, go nodes 400000. The first instinct is a time limit, say half a second a move. But a phone searches fewer positions in half a second than a laptop does, so the same game would get different verdicts on different devices. A depth limit has the opposite problem: the same depth is quick in a quiet position and slow in a sharp one.
A node count gives the same answer on every device and takes about as long in every position. On a recent laptop that is about 0.35 seconds a position, so a 40-move game takes around half a minute. An older phone needs a few minutes. The review fills in from the first move while it runs, so you can start reading before it is done.
A third engine
The app already runs two engines, one to play against and one for hints. The review starts a third one in its own Web Worker when you open it, and shuts it down when you close it. The other two sit idle in the meantime, so they do not compete for the CPU.
Results are stored by position in your browser, for the last game you reviewed. Open the review again after a reload and it appears at once. Like everything else in the app, it never leaves your device.
The judging, the search order, the cache and the graph are plain functions without any access to the page, with 26 unit tests between them.
"Everything checks out"
I built this with Claude Code, like the rest of the app. Once the feature worked, I asked it to review the uncommitted code and to tell me when everything was perfect.
It ran the linter, the type checker, the 80 unit tests and the production build. It imported a game in the browser, stepped through the review and checked the formulas against Lichess. It made three small cleanups and reported that everything checked out.
Then I opened SonarQube for IDE, the free Sonar plugin for code editors. It listed 36 issues in 8 files.
Four functions over its complexity limit of 15, scoring 19, 23, 25 and 28.
Nine nested ternaries and seven nested template literals.
Seven CSS selectors defined twice.
Five button groups marked up with
role="group"where a fieldset does the same job natively, and a table without header cells in its markup.Three small ones: an optional chain,
String.rawand.at(-1).
None of them was a bug. The review had that right: the feature worked. These are findings about maintainability and accessibility. They do not break anything today. They make the next change slower and riskier. And 21 of the 36 were in code that had been live since the launch, so this was not only about the new feature.
Why the review missed them is simple. The project lints with typescript-eslint in strict mode, which has no complexity rule and no opinion about nested ternaries. Claude Code checked the code against the bar the project had set, and the code cleared it. The bar was too low.
Fixing them
Each long function was split into small named helpers with the same output. Running Sonar's TypeScript rules over the whole codebase then found two more issues in older files. The worst was the function that draws the board, with a complexity of 46 against the limit of 15.
A refactor like that is where a bug slips in without anyone noticing. So before trusting it, a throwaway test drew the board with the old code and the new code in 24 combinations: three positions, one of them with check and one with a capture on offer, both orientations, and different squares selected. The markup was identical every time. The test was deleted afterwards. It had done its job.
The Sonar rules now report nothing for the whole codebase.
Making it stick
Fixing 36 issues once is not the point. The point is that the next ones get caught without anyone opening a plugin. So the project now lints with Sonar's own rule set for JavaScript and TypeScript, eslint-plugin-sonarjs, next to typescript-eslint. Every push runs it in CI, and a finding fails the build before anything deploys.
Switching on the full recommended set found four more issues that the plugin report had not listed. The engine output parser moved its loop counter inside a for loop, a check was written inverted, a type was declared optional twice, and the build ran git from the PATH. The last one is fine for a build step and is now marked as such. The other three are fixed.
What is next
Offline support, and importing your finished games from Lichess and Chess.com so you can review them here.
Check yours
Install SonarQube for IDE in VS Code or a JetBrains editor. It is free and works without an account. Open a project you care about and read the report. If nobody has done that before, expect a list.
And if you buy code, from an agency, a freelancer or an AI agent, ask what checked it besides the tests. "The tests pass" and "it is done" are two different measurements.
Try the review at chess.gunnie.nl. The code is at github.com/gunnie-agency/gunnies-gambit.