Compiling a xiangqi engine for the browser
Pikafish is the strongest open-source engine for xiangqi (Chinese chess)—a C++ fork of Stockfish built for a nine-by-ten grid divided by a river, palatial fortresses, and tactical rules that behave nothing like Western chess. web-xiangqi compiles Pikafish to WebAssembly, serves its 53.7 MB neural network from a static host, and wraps an analysis and game-review studio around it. Every interactive board below runs the app’s own rule and notation code directly in the page, paired with evaluations from the Pikafish build the app ships.
The rules
Even if you play Western chess every day, four xiangqi rules will catch you off guard: a horse’s leg can be blocked on the orthogonal step before its diagonal turn; an elephant’s eye too at the midpoint of its two-point diagonal jump (and elephants may never cross the river); a cannon captures over exactly one screen while sliding like a rook when moving to an empty point; and the generals may never face each other along an open file with no piece between them.
Before the UI ever sends a position to the engine, its own TypeScript move generator has to agree with Pikafish on every legal move. A perft test suite pins the two together to the leaf: 44 moves from the starting board, 1,920 two-move sequences, and 79,666 three-move sequences.
Writing a move down
Xiangqi notation reads like a four-character sentence rather than an algebraic square, with files numbered 1 to 9 from each player’s own right-hand side. In the classic central-cannon opening 炮二平五 (C2=5 in World Xiangqi Federation letters), Red takes the cannon (炮) on file two (二) and slides it horizontally (平) to the fifth file (五). When a piece advances (進, +) or retreats (退, −), the final character changes role depending on the piece: for pieces that travel straight along a file, it counts the steps taken; for diagonal pieces like horses, elephants, and advisors, it names the file they land on.
You can play 炮二平五 on the board above, or switch the pieces to letters if you are still learning the characters (Red and Black even use different characters for their own counterparts, such as 仕 and 士 for advisors). To parse a move a user types—whether in Chinese, WXF letters, or UCI coordinates—the app simply renders every legal move in the current position across all three notations and keeps the one that matches.
Compiling Pikafish
Pikafish is written as a command-line program that blocks on stdin waiting for UCI strings. A browser worker has no stdin to block on, so the heart of the 340-line patch turns main() into initialization and exposes a single C function that dispatches one command at a time:
extern "C" void command(const char* cmd) {
uciP->process_command(cmd);
}
The rest of the patch stubs out native threads for the single-threaded builds and removes NUMA node binding, producing four compact WASM binaries (~700 KB each) that the loader chooses between at startup. The compiler itself had to be pinned as tightly as the C++ source: Emscripten 6.0.2 quietly dropped a flag that told pthread workers where the engine’s script lived, turning every spawned thread into new Worker(undefined) so the engine answered uci and then hung forever before readyok.
Beside the 700 KB core sits pikafish.nnue, a 53.7 MB weight file that accounts for 97.6% of a first visit’s download (and is already Zstandard-compressed, so gzipping it on the server actually makes it 8.8 KB larger). Because browsers refuse to store a 53 MB response in the standard HTTP cache, the app’s service worker stores the network in Cache Storage itself—and attaches the two cross-origin isolation headers that GitHub Pages cannot send, taking an eight-thread search from 414,000 nodes a second to 3.7 million.
The halfmove clock
Every FEN string ends with a halfmove clock counting plies since the last capture. Where chess draws after 50 moves (100 plies), xiangqi enforces a 60-move rule (120 plies), and Pikafish reads that counter directly from the FEN when searching.
In the horse-and-advisor endgame above, forcing mate in three requires five plies. That means the forced mate is still there at a clock of 115 (115 + 5 = 120, delivering mate on the final legal ply) and vanishes into a 0.00 draw at 116. Early versions of web-xiangqi wrote a hardcoded 0 into the clock field of every FEN and ignored the clock on pasted positions—which caused endgames pasted near the 60-move limit to promise forced mates in positions the engine itself would have declared drawn.
Repetition
Repetition is where xiangqi breaks hardest from Western chess. Repeating a board position three times is not an automatic draw; the outcome depends on who is forcing the loop. A player who checks on every move of a cycle loses unless they deviate, and so does a player who perpetually chases an unprotected piece.
The app’s own TypeScript detector catches perpetual check as soon as the cycle forms, since every recorded move already carries a check flag. It deliberately does not guess at perpetual chase: deciding whether a move legally “chases” requires knowing which piece became newly attacked, whether it is defended, and a tangle of exceptions for pinned pieces, generals, and soldiers. As the source comments put it, guessing at that would be worse than saying nothing—so the UI flags an unverified loop as a draw unless a chase is involved, while the WASM patch exposes Pikafish’s internal rulejudge command, which adjudicates both lines above.
Reviewing a game
The review pipeline scores every position at depth 16, maps centipawns onto a win-probability curve, and grades each move by the milder of its centipawn and winning-chance losses. In the game above, play stays roughly level through a pair of mutual stumbles around move 32 until Red steps its general off the back rank with 45.帥五進一, dropping from 43% winning chances to 17% and walking into mate eleven plies later.
That showcase game was played between two of the app’s own AI levels (Easy and Medium) rather than lifted from a book, and for good reason. web-xiangqi once shipped ten “classic grandmaster games” complete with player names, tournament titles, and dates. None of them were real: seven shared the exact same eight-move opening block, and two contained illegal moves that had been loading silently truncated. What gave the fabrication away was pointing the app’s own review engine at them—the supposed grandmasters scored 20% to 28% accuracy with seven to ten blunders apiece, barely ahead of the 7% scored by random legal moves.