← Index

Chess Trainer

2026engine · evalLiveRepo

Play a rated bot, then run a full Stockfish sweep over the game that labels every move — Book, Brilliant, Great, Best, Excellent, Good, Inaccuracy, Mistake, Blunder — and feeds your blunders back as spaced-repetition drills. The engine runs in the browser as WASM; there is no analysis server.

The labels look like chess.com's. Getting them to mean anything took three separate layers, and each one exists because the obvious version was measurably wrong.

1. Loss is measured between siblings, not across time

The intuitive way to score a move is eval(before) - eval(after). This is wrong, and wrong in a way that poisons everything downstream: those two searches are one ply apart with opposite sides to move, so they don't share a frame of reference. Every move shows a phantom loss.

The sweep instead evaluates the position after the engine's best move, and diffs it against the position after the move you actually played. Same side to move, same depth, same frame. The difference is now a real quantity.

2. Evaluations are clamped before subtraction

Raw centipawn loss falls apart in decided positions. The measured failure, documented in the source: 75% of flagged moves sat beyond ±10 pawns, 53% pinned at the loss cap, and the same games reported ACPL of 115–253 alongside accuracy of 78–96%.

Both numbers were computed correctly. They disagreed because accuracy passes through a saturating logistic — at +12 pawns, dropping two pawns barely moves your win probability — while raw ACPL treats that as a catastrophe. Clamping evaluations to ±1000cp before subtracting puts the two metrics back in the same universe.

3. The labels are a product layer, not engine output

Stockfish does not have an opinion about whether a move was brilliant. The ladder is ours, and it's ordered:

if (sacrificed && lostCp <= EXCELLENT_CP && held !== null && held >= SACRIFICE_HOLDS_CP)
  return 'brilliant';
if (playedIsBest && secondBestGapCp !== null && secondBestGapCp >= ONLY_MOVE_GAP_CP)
  return 'great';
if (playedIsBest) return 'best';
if (lostCp <= EXCELLENT_CP) return 'excellent';
if (lostCp <= GOOD_CP) return 'good';

"Great" requires that your move was best and the second-best was at least 150cp worse — an only-move. That single requirement is why the analysis engine runs MultiPV 2 at depth 12: the second-best line exists purely so the gap is measurable. "Brilliant" needs a static-exchange-approximated sacrifice of at least 100cp that still holds at −50cp or better, because a sacrifice that loses is just a blunder with confidence.

The thing I'd tell you to copy

Every game stores ANALYSIS_VERSION alongside its depth and engine id. When the thresholds change — and they have, three times — the UI knows which numbers are comparable and a backfill knows exactly what to redo.

And the sweep lives in one shared module, with a comment explaining why:

Two implementations of this is exactly how the scoring bug survived: the drill graded one way and the review another.

The rating band is a rough indicator calibrated at depth 12, not an Elo estimate, and the source says so. Book detection only knows the shipped repertoire, so it under-labels openings compared to chess.com. Both are honest limits rather than bugs.