Build a Real Opponent (Bonus)

Build a Chess Game with Rust and WebAssembly

Lesson 8 shipped an opponent that trades its queen for a pawn. This optional finale replaces it with one that doesn’t: a static evaluation, an alpha-beta search, and difficulty that exists as two knobs you can point to in the code. It lives on its own tag, bonus-engine, so the main lesson history stays exactly what the core course built. Checkpoint: bonus-engine.

A position is worth centipawns

A new module, chess-engine/src/search.rs, keeps the same discipline as the rest of the crate: no JS types, everything cargo test-able. Its foundation is a function that prices a position for the side to move — material (pawn 100, knight 320, bishop 330, rook 500, queen 900) plus a small centralization bonus computed arithmetically from a square’s distance to the board’s center, instead of the 6×64 piece-square tables a serious engine would carry. Knights and bishops earn up to 12 centipawns for sitting in the middle; that alone stops the engine developing its rook to a3 for no reason.

Search is organized pessimism

The recursive core is negamax with alpha-beta pruning — twenty lines that look ahead by assuming the opponent always answers with their best move:

let mut moves = pos.legal_moves();
moves.sort_by_key(|m| if m.is_capture() { 0 } else { 1 });

let mut best = -MATE_SCORE * 2;
for m in &moves {
    let next = pos.clone().play(*m).expect("legal move");
    let score = -negamax(&next, depth - 1, -beta, -alpha);
    if score > best { best = score; }
    if best > alpha { alpha = best; }
    if alpha >= beta { break; }
}

The negation is the whole idea: my score is the opposite of the best my opponent can do next. Alpha-beta adds the break — once a line is worse than something the opponent already has, stop reading it — and the sort above it is what makes that cutoff fire early; searching captures first means the refutations show up sooner. Terminal positions score checkmate at -MATE_SCORE (from the mated side’s view) and every draw at zero, which means the search finds stalemate traps all by itself.

Difficulty is two numbers

The knobs are a struct, not a mystery:

pub struct Difficulty { pub depth: u8, pub noise: i32 }

match level {
    0 | 1 => Self { depth: 1, noise: 120 }, // sees captures, misses tactics
    2 => Self { depth: 2, noise: 40 },      // punishes hung pieces
    _ => Self { depth: 4, noise: 0 },       // finds real tactics, no mercy
}

depth is how far ahead it reads. noise is deterministic jitter added to each root move’s score by a xorshift64 generator — the honest version of lesson 8’s one-shot hash, its state advancing per candidate move but seeded from the ply count, so games are still reproducible and there’s still no getrandom in the tree. Low levels play worse for a reason you can articulate: with ±120 centipawns of fog, a mistake has to be bigger than a pawn to be reliably avoided. That’s a far more human kind of weakness than blundering by script.

Wiring is the established pattern end to end: try_make_engine_move(level) in the plain impl, a make_engine_move export, rebuild, and one new Lit property — difficulty, default 0 — that routes scheduleCpuMove to the searching engine when positive. <chess-board difficulty="3"> in index.html is the whole activation, and every existing test stays green because the default preserves lesson 8’s opponent exactly.

The new tests read like a résumé: it finds a back-rank mate in one, takes a hanging queen, saves its own attacked queen at depth 2 (at depth 4 it’s allowed to win the pawn a move later instead — the test comment explains why forcing that would be wrong), plays identically given the same seed, and returns an error when mated. Eight new tests, 27 total.

Build it

File Action What goes in it
chess-engine/src/search.rs write Difficulty, evaluate, negamax, best_move, the xorshift noise, six tests
chess-engine/src/lib.rs modify mod search;, try_make_engine_move, the make_engine_move export, two integration tests
chess-engine/pkg/ generated Rebuild, delete the dropped .gitignore, commit
src/chess-board.ts modify The difficulty property and the branch in scheduleCpuMove
index.html modify Set difficulty="3" on the element when you’re ready to lose

Done when: npm run test:rust reports 27 passed, and at difficulty 3 the machine takes your hanging pieces instead of ignoring them.

Answer key: lesson-13...bonus-engine.

Challenge: the difficulty selector

The property exists; the UI doesn’t. Add a small <select> to the panel that sets difficulty at runtime, persist the choice alongside the other localStorage keys, and decide what should happen if it changes mid-game. That last question has no clean answer, which is what makes it the right final exercise.

That’s the end of the road. You’ve carried Rust into a browser, tested it at three layers, polished it until it glides, and built an opponent that earns its thinking delay. The repo’s tags are yours as reset points for whatever you build on top.