A xiangqi (Chinese chess) engine in one HTML file: move generation, perpetual check and an AI in a Web Worker
A developer built a complete xiangqi (Chinese chess) engine into a single 155,830-byte HTML file (53,801 bytes gzipped) that runs entirely offline with no network requests, using AI agents (Claude Code) under human direction for the Ant Games site. The engine represents the board as an Int8Array(90) with one-byte pieces and single-integer moves, precomputes all geometry at load time so move generation does no geometry, and checks legality by treating the enemy general as a file-only chariot in its inCheck routine. The AI opponent runs in a Web Worker to keep the page responsive, alongside three difficulty levels, synthesized music and 135 Tang poems.
CHINESE CHESS is a xiangqi game you play against the computer in a browser tab. It is one of the free games on Ant Games antgames.ai https://antgames.ai/ , our own site: no ads, no purchases, no sign-up. It was built by AI agents Claude Code directed by a human owner, and you can play it here https://antgames.ai/play/chinesechess/ . The interface is in English. The whole game is one HTML file of 155,830 bytes 53,801 gzipped . It holds the engine, three levels, synthesised music and 135 Tang poems, and makes no network request. This post is about the engine: the board, move generation, the rules that trip people up, and how the opponent thinks without freezing the page. The snippets are copied from the game's TypeScript source; the rules engine and search are about 1,050 non-blank lines. Xiangqi is played on the points of a 9 by 10 grid, with a river across the middle and a 3 by 3 palace on each side. Each side has a general and two advisors both stay in the palace , two elephants two points diagonally, never across the river , two horses, two chariots, two cannons and five soldiers. Four rules matter for the code: A position is an Int8Array 90 , point rank 9 + file , rank 0 being Black's back rank. A piece is one byte, type | colour + 1 << 3 , so Red pieces are 9 to 15 and Black pieces 17 to 23, and "is this mine?" is one AND against 8 << side . A move is from | to << 7 in one integer. All the geometry is worked out once at load time in tables.ts : for every point, where a general, advisor or soldier can step, and four rays for the sliding pieces. The horse and elephant tables store pairs, the target and the point that must be empty to, leg, to, leg, ... , and the elephant table has the river built in. Move generation then does no geometry, and the blocked horse or elephant is one comparison, if b st i + 1 == 0 continue; , commented "the eye, or the leg, is blocked". The cannon is two loops over the same ray. The first walks empty points as quiet moves. The second starts after the first piece it meets the screen and looks for the next piece beyond it: js case CANNON: { const rays = RAYS s ; for let d = 0; d < 4; d++ { const ray = rays d , len = ray.length; let i = 0; for ; i < len; i++ { const t = ray i ; if b t == 0 break; if capsOnly out at++ = s | t << 7 ; } for i++; i < len; i++ { const t = ray i , q = b t ; if q == 0 { if q & opp out at++ = s | t << 7 ; break; } } // over exactly one piece } break; } The generator is pseudo-legal: it does not ask whether the mover's general is left attacked. Legality is checked by making the move and asking inCheck . This is the decision I would copy first. Instead of a separate "generals facing" test, inCheck treats the enemy general as a chariot that only attacks along the file. It looks outward from the general being tested: the points a soldier or horse could attack from each horse point paired with the leg that must be empty , then the four rays. The first piece on a ray is a chariot or a facing general. The second, behind a screen, is a cannon: js inCheck c: Color : boolean { const b = this.board, k = this.kings c , o = c ^ 1, base = o + 1 << 3; const pawn = base | PAWN, horse = base | HORSE, rook = base | ROOK, cannon = base | CANNON, king = base | KING; const pf = PAWN FROM o k ; for let i = 0; i < pf.length; i++ if b pf i === pawn return true; const hf = HORSE FROM k ; for let i = 0; i < hf.length; i += 2 if b hf i === horse && b hf i + 1 === 0 return true; const rays = RAYS k ; for let d = 0; d < 4; d++ { const ray = rays d , len = ray.length; let i = 0; while i < len && b ray i === 0 i++; if i = len continue; const p = b ray i ; if p === rook || p === king && d < 2 return true; i++; while i < len && b ray i === 0 i++; if i < len && b ray i === cannon return true; } return false; } d < 2 means up or down the file. A move that opens the file between the generals leaves the mover "in check" and is rejected by the same code that rejects any other move into check. The generator is tested by perft from the opening position 44, 1,920, 79,666 and 3,290,240 positions at one to four plies and against a second generator in the tests, "written slowly and plainly from the rules and sharing no code with src/sim", on every position of 60 random games: over 4,000 positions and 100 checks. make updates everything incrementally: the board, the general's square, a 64-bit Zobrist hash kept as two 32-bit halves hashL , hashH , and the evaluation. It also pushes onto parallel typed-array stacks: the move, what it captured, whether it gave check, and the hash before it. unmake restores the hash from the stack. Those stacks do double duty: repetition and perpetual check are read straight from them, in the search and in the game alike. The rules of a whole game sit in Match.judge , called after every move: js private judge : void { const pos = this.pos, side = pos.side; if pos.hasLegalMove { this.end other side , pos.inCheck side ? 'checkmate' : 'stalemate' ; return; } const c = this.cycle ; if c.count = 3 { if c.selfAll && c.oppAll this.end other side , 'perpetual' ; else if c.oppAll && c.selfAll this.end side, 'perpetual' ; else this.end -1, 'repetition' ; return; } if pos.attackers 0 === 0 && pos.attackers 1 === 0 { this.end -1, 'material' ; return; } if this.quietPlies = MOVE LIMIT this.end -1, 'moveLimit' ; } cycle walks back through the hash stack, but only to the last capture, since a position cannot recur across one. It counts earlier occurrences of the current position with the same side to move, then checks whether each side gave check with every one of its moves since the first occurrence. On the third occurrence, a side that checked every time while the other did not loses; anything else is a draw. A game is also drawn after 120 plies without a capture MOVE LIMIT , sixty moves each , or when neither side has a chariot, cannon, horse or soldier left, the pieces that can cross the river. Be clear about what this is not. The tournament rules for xiangqi repetition also forbid perpetual chasing, endlessly attacking an unprotected piece without check, and have detailed cases for mixed check-and-chase cycles. This engine implements perpetual check only; a perpetual chase is ruled a draw by repetition. That is a simplification for a game against the computer, and it is the first thing I would add for a rules-strict implementation. Inside the tree, repStatus reads the same stacks, and a single repeat is enough, as usual in engines. if self && opp return -BAN + ply; scores a line where the side to move has checked forever as lost, and a plain repeat is worth CONTEMPT = 20 below zero for the engine's side. These path-dependent scores are never stored in the transposition table. Before any search, the opponent also drops every root move that forfeits says would lose on the spot. The player gets the same protection: such a move is refused with "Perpetual check is forbidden. Choose another move." whenever another move exists, and a warning appears after the second occurrence perpetualRisk . search.ts is conventional: iterative deepening, alpha-beta with a principal variation null-window searches after the first move , a transposition table, null-move pruning, late-move reductions, check extensions and a quiescence search over captures. from 90 + to history table that grows by depth depth on a cutoff. Moves are picked one at a time, not sorted. depth - 3 , only when not in check and only for a side with enough pieces rooks 2 + cannons + horses = 2 , because zugzwang is common in thin xiangqi endings. -MATE + ply . There is no separate stalemate case, because the rule makes both a loss. The evaluation is the weakest part. It is material plus piece-square tables, plus 3 for having the move: a soldier before the river is worth 100, a horse about 430, a cannon about 450, a chariot about 920. There is no mobility, no king safety beyond the general's table, and no idea of a hanging piece outside the quiescence search. I would add a cheap mobility and attacked-piece term next, kept only if it wins against the current version. The levels are not different engines. They are numbers in difficulty.ts : | level | depth cap | positions per move | time in the browser | may stray | random move | |---|---|---|---|---|---| | Easy | 2 plies | 6,000 | 500 ms | 100 | 12% | | Medium | 4 plies | 60,000 | 1,500 ms | 24 | 2% | | Hard | 14 plies | 1,200,000 | 4,000 ms | 0 | 0% | Hard's 14 plies are a ceiling: the search also stops at 1.2 million positions, at 4 seconds, and starts no new iteration once half the node budget is spent. I did not measure the depth it usually reaches. For the first 8 plies of a game from the usual start, Hard searches 5 plies and strays up to 14, so that no two games open alike. "May stray" is the interesting part. For Easy, Medium and Hard's opening, scoreAll scores every root move within the margin of the best, and the move is drawn with weights that fall off exponentially with the gap. A weaker level plays plausible second-best moves, not random ones: js if margin 0 { const scored = s.scoreAll m.pos, limits, margin, list ; let best = -Infinity; let top = scored 0 ; for const x of scored if x.score best { best = x.score; top = x; } // a game that is decided is not strayed in: the quickest win, or the longest defence if best WIN || best < -WIN return { move: top.move, score: top.score, depth, nodes: s.nodes, kind: 'best' }; const w = scored.map x = Math.exp - best - x.score / Math.max 1, margin / 2 ; let r = rng.next w.reduce a, b = a + b, 0 , pick = scored scored.length - 1 ; for let i = 0; i < scored.length; i++ { r -= w i ; if r <= 0 { pick = scored i ; break; } } return { move: pick.move, score: pick.score, depth, nodes: s.nodes, kind: 'margin' }; } Once a forced mate is found, the level stops straying. The gaps were measured in self-play: a ladder of in-between levels played matches, and the scores became Elo differences, recorded as Medium about +1,389 and Hard about +2,086 above Easy 120 games . The source itself calls them rough, because neighbouring steps won 19 games of 20. Randomness is seeded mulberry32 , and a test checks that a seed replays the same game with the time limit off. One weakness is plain: Easy's 12% random move is rolled before the search. On roughly one move in eight Easy plays a random legal move without looking, so it can miss a mate in one the mate-in-one test sets blunder: 0 . I would make the random move skip forced wins. A one-file game has no worker.js to load. The game starts its own script a second time as a Web Worker: if typeof document === 'undefined' { runWorker self as unknown as Parameters