Mining Xiangqi Puzzles from Real Games
Xiangqi has no public puzzle corpus. Chess has millions of positions mined from real games, published and free; the xiangqi puzzles you can find online are hand-composed endgame studies, which are a different thing and a much smaller supply.
So I built a miner. It reads real games, finds the moves people got wrong, and keeps the positions where exactly one move wins. It currently serves 1,415 puzzles on Mistboard, and there’s a twelve-position sampler you can play through move by move.
Here is today’s, live off that corpus. Play the moves on the board; attempts inside a frame are never rated, so nothing is at stake.
The rest of this is how one of those gets made, and what a corpus built that way turns out to look like.
The games
They come from ElephantChess, which publishes its own site’s games as anonymised monthly dumps under GPL-3.0. Amateur games, which matters: strong players don’t blunder often enough to be a supply. Every source carries a licence status, and publication refuses any puzzle whose source isn’t cleared.
A run freezes its game list before any engine time is spent, sampled across ratings, time controls, results and lengths so it doesn’t turn out to be all blitz. Nothing gets added once a run starts, which is what makes it reproducible.
Two passes, and why the split pays
The whole economics of this is one asymmetry: the cheap pass runs on every position of every game, and the expensive one runs only on what survives it.
The cheap pass replays a game and stops at every position after move 8, asking Pikafish for its top two moves at 60,000 nodes, roughly depth 10 to 14.
A position becomes a candidate when the move actually played loses at least 250 centipawns against the engine’s best, and the position it leaves behind is winning by at least 250 for the other side. A blunder that leaves the game equal is not a puzzle; there’s nothing to find.
One detail halves this pass. Judging a move needs the position’s value before and after, and both are already in hand if the scans stay in order, because the move played is worth the negation of the next position’s score:
for (let ply = minPly; ply < moveCount; ply += 1) {
const pre = scans[ply]; // the best that was available
const post = scans[ply + 1]; // what they left behind, opponent's view
if (pre === null || post === null) continue;
if (Math.abs(pre) >= decidedCp) continue; // already decided: no tactic
if (post < winCp) continue; // solver must end up winning
const playedCp = -post; // the move, in their own terms
const swing = pre - playedCp;
if (swing < swingCp) continue; // a mistake, but a small one
candidates.push({ ply, swingCp: swing, preBestCp: pre, postBestCp: post });
}
One search per position instead of two, on the pass that touches every position of every game. That halving is what makes scanning a whole corpus affordable: about six core-hours per thousand games, roughly one core-minute per published puzzle.
The expensive pass then takes each candidate back to the engine at depth 20 and 600,000 nodes, ten times the budget, handed over as a bare FEN with no move history. Same position, no context, so the engine can’t lean on the search it just did.
What makes it one answer
The solution gets built one move at a time, and every move has to be uniquely best on its own. Taking the engine’s principal variation whole doesn’t work: a PV is one line the engine liked from one search, and it says nothing about whether move three was forced. A solver who finds a different move three and is told they’re wrong has been lied to.
Uniqueness also isn’t a centipawn gap. Two moves 50 centipawns apart are both fine, and demanding one punishes a solver for choosing correctly. What makes a move the answer is that every alternative is wrong: it gives the win away, or it wins materially less.
function classifySolverMove(best, second) {
// Mate saturates both centipawns and win%, so mates get their own rule:
// unique only when this is the strictly fastest forced mate.
if (mates(best)) {
if (!second || !mates(second)) return { unique: true, reason: 'fastest-mate' };
return best.mate < second.mate
? { unique: true, reason: 'fastest-mate' }
: { unique: false, reason: 'mate-not-unique' };
}
if (winRate(best.scoreCp) < 0.8) return { unique: false, reason: 'best-not-winning' };
if (!second) return { unique: true, reason: 'only-move' };
const gapCp = best.scoreCp - second.scoreCp;
if (gapCp < 200) return { unique: false, reason: 'near-tie' };
// The runner-up is wrong if it gives the win away outright...
if (winRate(second.scoreCp) <= 0.6) return { unique: true, reason: 'runner-up-loses-win' };
// ...or if it still wins, but wins a whole piece less.
if (gapCp >= 250) return { unique: true, reason: 'material-gap' };
return { unique: false, reason: 'alternative-still-good' };
}
It fails closed. Anything the gate can’t separate is thrown away, and the reason is stored on the candidate, so every rejection is auditable after the fact.
A second pass audits the survivors at depth 22 with no node ceiling, in a different process, with no knowledge of what the first pass decided. About 6% of them don’t hold. That disagreement rate between two runs of the same engine at different budgets is the argument for having the second pass at all: one engine at one depth is not a source of truth about its own verdicts.
Rating a line nobody has solved yet
A line can be verified, unique, and still make a bad puzzle, and the thing that decides which is difficulty.
The naive rating is mate depth, which gives four distinct values for a whole corpus. The real one walks the solution and scores what makes a position hard to see: a quiet first move adds, a capture subtracts, an unrecovered sacrifice adds up to 200, and the number of defensive replies pushes either way. A capture that nothing can recapture takes the largest penalty of all, scaled by the material it wins, because a hanging piece is the easiest thing on a board to spot:
// Only the solver's first move, and only while the game is still running: a
// capture that MATES also leaves the opponent no legal moves, and reading that
// as "nothing can recapture" would penalise every mating capture in the corpus.
if (index === 0 && captured && state.status.type === 'playing') {
const recaptures = getLegalMoves(state).filter((reply) => reply.to === move.to);
if (recaptures.length === 0) freeCaptureCp = MATERIAL_CP[captured.role];
}
That guard is the part worth stealing: the obvious version of the check is wrong in a way that passes every test you’d think to write, because a mating capture also leaves the opponent with no legal replies.
One class of position is withheld rather than rated. A puzzle where the solver was already winning by more than a horse and the answer is to take something undefended teaches nothing at any rating, and rating can only decide who gets shown a problem.
What the corpus turns out to look like
Three chapters from the sampler, each carrying one of the numbers.
Two thirds of the puzzles open with a move that captures nothing. If you hunt for tactics by scanning the captures first, which is what most of us do, you’re looking at the wrong third of the board most of the time. In this one, from a Guangdong against Shandong team match in April, neither side captures anything anywhere in the line and it still ends in mate:
Only about a tenth involve giving material away. Sacrifices are the tactics people remember, so I’d assumed they’d be a larger slice. In real games between real players, the winning move is usually just a move. Here the chariot crosses the board to c1, steps into f1 to be taken, and the horse mates on d1:
And 40% don’t end in mate at all. They end with the solver simply winning, which is the kind a mate-shaped intuition misses. The winning move below is a king step: Red’s general goes from e2 to e1, takes nothing, threatens nothing, and the trades three plies later leave Red 650 centipawns better off.
| Served puzzles | 1,415 |
| Rating range | 1000 to 2600, 479 distinct |
| Open with a non-capture | ~two thirds |
| End in something other than mate | ~40% |
| Give material away | ~10% |
What a puzzle turns out to be
Most winning positions have several winning moves, and that’s what disqualifies them: a third of everything the miner finds dies on that alone. A puzzle is a position with one answer, deep enough that finding it takes work, and stable enough that a stronger engine still agrees an hour later. Nine out of ten mistakes don’t qualify, which is the real reason nobody has a big xiangqi puzzle corpus lying around: the supply problem isn’t finding blunders, it’s that almost none of them are puzzles.
Play them
The corpus is free and needs no account. The sampler is twelve mined positions with a note on what each one demonstrates; the trainer serves all 1,415, rated.