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.