A xiangqi (Chinese chess) engine in one HTML file: move generation, perpetual check and an AI in a Web Worker
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), our own site: no ads, no purchases, no sign-up. It was built by AI agents (Claude Co
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), 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. 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.
Just enough rules
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 horse moves one point straight, then one diagonally outward, and is blocked if the first point is occupied. An elephant is blocked by a piece on the point between ("the eye").
- A cannon moves like a chariot but captures only by jumping exactly one piece, the screen.
- The two generals may never face each other on an open file.
- A side with no legal move has lost, whether or not it is in check. Stalemate is a loss, not a draw.
The board: 90 numbers and some precomputed tables
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:
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.
The flying general is a chariot
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:
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 and unmake, with a history the rules can read
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.
Repetition and perpetual check: what is actually implemented
The rules of a whole game sit in Match.judge(), called after every move:
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()).
The search
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.
- Transposition table: 2^18 entries in six parallel typed arrays, about 3.7 MB, indexed by the low hash half, verified against both halves, always replaced.
-
Move ordering: hash move, then captures by most valuable victim and least valuable attacker, then two killers per ply, then a
from * 90 + tohistory table that grows bydepth * depthon a cutoff. Moves are picked one at a time, not sorted. -
Null move: the pass is searched at
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. - Late-move reductions: quiet, non-checking moves after the fourth lose 1 ply (2 after the fourteenth at depth 5 or more) and are searched again at full depth if they surprise.
-
Mate and stalemate: a node with no legal move returns
-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.
Three levels from one search
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:
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.
Off the main thread, in a single file
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<typeof runWorker>[0]);
} else {
const source = (document.currentScript as HTMLScriptElement | null)?.textContent ?? null;
The page passes that text to Engine, which wraps it in a Blob, makes an object URL and starts new Worker(url), so no second file is needed. Each request carries the starting FEN and the full move list, not just the current position. The worker replays the game, so it knows every repetition, and its transposition table survives from move to move. If no worker can be made, the search runs on the page after two animation frames, capped at 450 ms and 150,000 positions: weaker than Hard, but the board stays responsive. Leaving a game terminates a busy worker and starts a fresh one.
Take-backs need almost nothing from the engine: one pops two plies (yours and the reply) with the same unmake(), at most three times a game, only on your turn. The next request sends the shorter move list.
What I would copy
-
Treat the facing generals as a chariot along the file inside
inCheck, and the rule costs nothing extra. - Keep one history stack per position (move, capture, gave-check, hash before) and read repetition and perpetual check from it, in the search and in the game alike.
- Weaken by margin, not by noise. Score the moves near the best and draw from them with exponential weights.
- Test the generator against a second, naive one as well as perft.
And what I would change: a chase rule, a richer evaluation, and an Easy level whose random moves never throw away a mate.
CHINESE CHESS runs in any modern browser: free, no ads, no purchases, no sign-up. The other games are on the Ant Games home page.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.