Four places Chinese chess rules go wrong

Horse-leg blocking, elephant-eye blocking, cannon screens and the flying-general rule. Splitting move generation into a geometry layer and a legality layer removes most of the mistakes.

Xiangqi is an order of magnitude more complex than gomoku, and the complexity lives in the exceptions: every piece has one, they are independent, and missing one raises no error — it just makes the AI play a stupid move.

Splitting generation into two layers removed most of the mistakes for me.

Two layers: geometry and legality

Layer Function Responsibility
Geometry pseudoMoves How the piece moves, and what blocks it
Legality legalMoves Is my king safe after this move

The legality layer is dumb but robust: play each candidate move, then check whether your own king is attacked or the two generals face each other, and discard it if so.

Funnelling the global constraint through one place beats repeating “would this expose my king” inside every piece.

Xiangqi has no separate “thou shalt not expose thy king” rule; it follows from “my king must not be capturable”. That kind of global constraint is naturally a post-filter.

Trap one: the horse leg

A horse moves in an L, but if the orthogonal square adjacent to it is occupied — by either side — that direction is blocked. Each of the eight directions has its own leg:

const HORSE = [
  [-2, -1, -1, 0], [-2, 1, -1, 0],
  [2, -1, 1, 0], [2, 1, 1, 0],
  [-1, -2, 0, -1], [1, -2, 0, -1],
  [-1, 2, 0, 1], [1, 2, 0, 1],
];

Each entry is “destination offset plus leg offset”. A table rather than eight if-statements, because the leg and the destination must stay paired — written apart, two directions easily end up sharing a wrong leg.

How to test it: on an empty board a horse has 8 moves; block each of the four legs once and 6 should remain. Fewer means the pairing is wrong, more means you blocked the wrong square.

Trap two: the elephant eye

An elephant moves two diagonally, and the square in the middle — the eye — blocks it. It also never crosses the river: a red elephant stays on rows 5 to 9.

One thing I got wrong initially: I assumed it could only move forward. It can retreat to its own back rank as long as the destination is still on its own half. The test is ownHalf(side, row), not “is it forward”.

Trap three: the cannon screen

A cannon moves in two regimes, and this is the easiest part of the whole thing to get wrong:

  1. Without capturing it moves like a chariot: along the line, any empty square, stopping before the first occupied one.
  2. To capture it needs exactly one screen: hop over the first piece, and the first piece after that is the one it may take.
// Regime one: empty squares before the screen
while (inBoard(nr, nc) && !board[index(nr, nc)]) {
  out.push({ from: i, to: index(nr, nc), captured: null });
  nr += dr; nc += dc;
}
// Hop the screen
if (!inBoard(nr, nc)) continue;
nr += dr; nc += dc;
// Regime two: the first piece after the screen
while (inBoard(nr, nc)) {
  const t = board[index(nr, nc)];
  if (t) {
    if (t.side !== side) out.push({ from: i, to: index(nr, nc), captured: t });
    break;
  }
  nr += dr; nc += dc;
}

What matters is not only what it may capture, but that empty squares behind the screen are also unreachable. I initially dropped that break, and the cannon flew to empty squares beyond its screen like it had wings.

Trap four: the flying general

The two generals may not end up on the same file with nothing between them — because that would expose both. Implementation-wise it is the same as check: play the move and test.

This constraint also wrecks hand-built test positions: put both generals on the same file by accident and the whole position is illegal, so legalMoves returns an empty array and looks broken. I lost a debugging round to exactly that, and the assertions now carry a comment saying the baseline position must keep the generals on different files.

Check detection has four cases too

To test whether a side is in check, do not enumerate the opponent’s moves — work backwards from the king:

Piece How to detect
Chariot First piece met along each of four directions
Cannon Piece met after the first along each direction
General First piece along each direction (facing)
Horse A horse on one of eight L-squares whose leg is clear

Mind the direction on the horse: with the horse at (nr, nc) and the king at (kr, kc), the leg is the square next to the horse facing the king — (nr + lr, nc + lc) using the same (lr, lc) entry. Forward and backward search sharing one table is another payoff of writing it as data.

The AI: look for mate in one first

Xiangqi kings are confined to a 3×3 palace, so mate in one is far more common than in gomoku, and a shallow search misses it. So every root candidate is played once to see whether the opponent has any reply; if not, return immediately without searching.

A side effect: a forced mate has a definite score, so the UI showing 90000 is immediately recognisable as mate.

Notation

I also implemented Chinese notation (炮二平五). The rule is tidy: red counts files one to nine from its right, black counts 1 to 9 from its left, and 进/退/平 mark forward, backward, or sideways.

I stopped at “readable” and skipped the 前/后 disambiguation for two pieces on one file — that is the job of dedicated notation software, and not worth it for a toy.

← Back to all posts

Comments

…