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.