The Cheapest Opponent That Works

Build a Chess Game with Rust and WebAssembly

Every chess app needs someone to play against, and this one hires the cheapest candidate who can do the job: about twenty lines of Rust, no search, no evaluation, and a random number generator that isn’t one. This lesson builds it, wires the UI around it, and — the actual point — prices exactly what those twenty lines do and don’t buy. Checkpoint: lesson-08.

Twenty lines, priced honestly

The whole opponent, in the plain impl block next to its siblings:

pub fn try_make_cpu_move(&mut self) -> Result<String, String> {
    let moves: Vec<Move> = self.board.legal_moves().iter().copied().collect();
    if moves.is_empty() {
        return Err("No moves available".to_string());
    }

    // Simple pseudo-random based on history length
    let seed = self.move_history.len().wrapping_mul(1103515245).wrapping_add(12345);

    let captures: Vec<Move> = moves.iter().copied().filter(|m| m.is_capture()).collect();

    let m = if !captures.is_empty() && seed % 3 != 0 {
        captures[seed % captures.len()]
    } else {
        moves[seed % moves.len()]
    };
    Ok(self.play_move(m))
}

Those two magic numbers are the glibc rand() constants — but this is not an RNG. A real LCG iterates its state; this applies the formula once to the ply count, a hash wearing a generator’s clothes. Given the same position at the same move number, it picks the same move, every game, forever.

Why dodge real randomness? Because rand on wasm needs the getrandom crate with its js feature flag to reach the browser’s crypto API, and that’s a dependency, a feature flag, and an initialization question — for an opponent this bad. The hash sidesteps the whole problem, and the bonus lesson does randomness properly when the opponent deserves it.

What the code buys: a legal move every time, and a 2-in-3 bias toward captures (seed % 3 != 0) that makes it feel like it wants something. What it doesn’t: the capture is picked arbitrarily — it will take a defended pawn with its queen, because nothing anywhere compares piece values. And one subtle interaction with lesson 3: try_set_fen clears the history, so after any FEN load the seed is 0, 0 % 3 == 0 skips the capture branch, and the reply is always shakmaty’s first generated move. Deterministic all the way down — which lesson 12’s browser tests will quietly rely on.

Scheduling the illusion

On the TypeScript side, one method gives the machine its turn:

private scheduleCpuMove() {
  if (!this.game || this.game.is_game_over() || this.game.is_human_turn()) return;
  setTimeout(() => {
    if (this.game && !this.game.is_human_turn()) {
      try {
        this.game.make_cpu_move();
        this.updateGameState();
      } catch (e) {
        console.error('CPU move failed:', e);
      }
    }
  }, 300);
}

The 300 ms is pure theater — the actual pick takes microseconds — but an instant reply reads as a bug to humans. Note that the guard consults the engine, not the component’s @state fields: once lesson 13 makes state application asynchronous, the fields can lag the truth, and this method is already written for that world. It’s called from two places: after a human move, and at the end of firstUpdated (so a player who chose black gets an opening move — try <chess-board playerIsWhite="false">… and see the freeze from lesson 7 resolve itself).

Saying whose turn it is

With two players, the interface needs a voice. A status bar joins the render: “Your turn”, “Computer thinking…”, “(Check!)” appended when appropriate, and “Game Over: 0-1” with a filled style when is_game_over() turns true. The check highlight rides along: find the checked king by searching the parsed board for the right glyph — whose king is in check is just the FEN’s side-to-move field — and pulse its square with --danger. applyGameState grows its inCheck and result reads, and one new Rust test pins the opponent’s contract: the move it plays is legal and lands in the history.

Build it

File Action What goes in it
chess-engine/src/lib.rs modify try_make_cpu_move, the make_cpu_move wrapper, the cpu_move_is_legal_and_recorded test
chess-engine/pkg/ generated Rebuild, delete the dropped .gitignore, commit
src/chess-board.ts modify scheduleCpuMove; inCheck/result state + reads; status bar markup and CSS; checked-king highlight; call sites after human moves and in firstUpdated

Done when: npm run test:rust reports 14 passed, and after 1. e4 the computer replies within a beat and the status returns to “Your turn”.

Answer key: lesson-07...lesson-08.

Challenge: make it twitchy

The 300 ms delay is constant. Scale it with the number of legal moves in the position — say 150 ms plus a few per candidate — so the machine “thinks longer” in complicated positions. Ten minutes of work, and watch how much smarter the same twenty lines suddenly feel.

Next lesson the game learns to survive: a reload mid-game comes back exactly where you left it, including when you left it on the computer’s turn.