{"slug": "a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check", "title": "A xiangqi (Chinese chess) engine in one HTML file: move generation, perpetual check and an AI in a Web Worker", "summary": "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.", "body_md": "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.\n\nThe 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.\n\nXiangqi 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:\n\nA 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.\n\nAll 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\".\n\nThe 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:\n\n``` js\n        case CANNON: {\n          const rays = RAYS[s]!;\n          for (let d = 0; d < 4; d++) {\n            const ray = rays[d]!, len = ray.length;\n            let i = 0;\n            for (; i < len; i++) { const t = ray[i]!; if (b[t] !== 0) break; if (!capsOnly) out[at++] = s | (t << 7); }\n            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\n          }\n          break;\n        }\n```\n\nThe 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`.\n\nThis 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:\n\n``` js\n  inCheck(c: Color): boolean {\n    const b = this.board, k = this.kings[c]!, o = c ^ 1, base = (o + 1) << 3;\n    const pawn = base | PAWN, horse = base | HORSE, rook = base | ROOK, cannon = base | CANNON, king = base | KING;\n    const pf = PAWN_FROM[o]![k]!;\n    for (let i = 0; i < pf.length; i++) if (b[pf[i]!] === pawn) return true;\n    const hf = HORSE_FROM[k]!;\n    for (let i = 0; i < hf.length; i += 2) if (b[hf[i]!] === horse && b[hf[i + 1]!] === 0) return true;\n    const rays = RAYS[k]!;\n    for (let d = 0; d < 4; d++) {\n      const ray = rays[d]!, len = ray.length;\n      let i = 0;\n      while (i < len && b[ray[i]!] === 0) i++;\n      if (i >= len) continue;\n      const p = b[ray[i]!]!;\n      if (p === rook || (p === king && d < 2)) return true;\n      i++;\n      while (i < len && b[ray[i]!] === 0) i++;\n      if (i < len && b[ray[i]!] === cannon) return true;\n    }\n    return false;\n  }\n```\n\n`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.\n\nThe 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.\n\n`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.\n\nThe rules of a whole game sit in `Match.judge()`, called after every move:\n\n``` js\n  private judge(): void {\n    const pos = this.pos, side = pos.side;\n    if (!pos.hasLegalMove()) { this.end(other(side), pos.inCheck(side) ? 'checkmate' : 'stalemate'); return; }\n    const c = this.cycle();\n    if (c.count >= 3) {\n      if (c.selfAll && !c.oppAll) this.end(other(side), 'perpetual');\n      else if (c.oppAll && !c.selfAll) this.end(side, 'perpetual');\n      else this.end(-1, 'repetition');\n      return;\n    }\n    if (pos.attackers(0) === 0 && pos.attackers(1) === 0) { this.end(-1, 'material'); return; }\n    if (this.quietPlies() >= MOVE_LIMIT) this.end(-1, 'moveLimit');\n  }\n```\n\n`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.\n\nBe 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.\n\nInside 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()`).\n\n`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.\n\n`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.\nThe 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.\n\nThe levels are not different engines. They are numbers in `difficulty.ts`:\n\n| level | depth cap | positions per move | time in the browser | may stray | random move | \n|---|---|---|---|---|---|\n| Easy | 2 plies | 6,000 | 500 ms | 100 | 12% | \n| Medium | 4 plies | 60,000 | 1,500 ms | 24 | 2% | \n| Hard | 14 plies | 1,200,000 | 4,000 ms | 0 | 0% | \n\nHard'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.\n\n\"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:\n\n``` js\n  if (margin > 0) {\n    const scored = s.scoreAll(m.pos, limits, margin, list);\n    let best = -Infinity;\n    let top = scored[0]!;\n    for (const x of scored) if (x.score > best) { best = x.score; top = x; }\n    // a game that is decided is not strayed in: the quickest win, or the longest defence\n    if (best > WIN || best < -WIN) return { move: top.move, score: top.score, depth, nodes: s.nodes, kind: 'best' };\n    const w = scored.map((x) => Math.exp(-(best - x.score) / Math.max(1, margin / 2)));\n    let r = rng.next() * w.reduce((a, b) => a + b, 0), pick = scored[scored.length - 1]!;\n    for (let i = 0; i < scored.length; i++) { r -= w[i]!; if (r <= 0) { pick = scored[i]!; break; } }\n    return { move: pick.move, score: pick.score, depth, nodes: s.nodes, kind: 'margin' };\n  }\n```\n\nOnce 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.\n\nOne 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.\n\nA one-file game has no `worker.js` to load. The game starts its own script a second time as a Web Worker:\n\n```\nif (typeof document === 'undefined') {\n  runWorker(self as unknown as Parameters<typeof runWorker>[0]);\n} else {\n  const source = (document.currentScript as HTMLScriptElement | null)?.textContent ?? null;\n```\n\nThe 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.\n\nTake-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.\n\n`inCheck`, and the rule costs nothing extra.\nAnd what I would change: a chase rule, a richer evaluation, and an Easy level whose random moves never throw away a mate.\n\n[CHINESE CHESS](https://antgames.ai/play/chinesechess/) runs in any modern browser: free, no ads, no purchases, no sign-up. The other games are on the [Ant Games home page](https://antgames.ai/).", "url": "https://wpnews.pro/news/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check", "canonical_source": "https://dev.to/antgamesai/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check-and-an-ai-in-a-1i5m", "published_at": "2026-11-15 00:14:33+00:00", "updated_at": "2026-11-15 00:23:06.741618+00:00", "lang": "en", "topics": ["artificial-intelligence", "ai-agents", "ai-tools", "developer-tools"], "entities": ["Ant Games", "Claude Code", "antgames.ai"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check", "markdown": "https://wpnews.pro/news/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check.md", "text": "https://wpnews.pro/news/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check.txt", "jsonld": "https://wpnews.pro/news/a-xiangqi-chinese-chess-engine-in-one-html-file-move-generation-perpetual-check.jsonld"}}