The engine can describe a board; now it learns to change one. This lesson
adds the move-making core, and with it the crate’s most important
structural rule: everything that can fail lives in plain Rust methods that
return Result<_, String> and never mention a JavaScript type. Checkpoint:
lesson-03.
A second impl block, and why
The struct gains two fields — move_history: Vec<String> and
last_move: Option<(Square, Square)> — and the file gains a second,
undecorated impl block:
/// Core game logic, free of JS types so it can be tested natively.
impl ChessGame {
fn play_move(&mut self, m: Move) -> String {
let san_str = San::from_move(&self.board, m).to_string();
self.last_move = Some((m.from().unwrap_or_else(|| m.to()), m.to()));
self.board = self.board.clone().play(m).expect("legal move");
self.move_history.push(san_str.clone());
san_str
}
Order matters inside play_move. SAN is computed before the move is
played, because disambiguation (“which knight went to f3?”) needs the
position the move came from. And play consumes the position and returns a
new one, hence the clone() — shakmaty makes an illegal-state-in-place
unrepresentable by never mutating.
That expect("legal move") is the crate’s single deliberate panic path.
Every caller validates before calling, and if that contract ever breaks,
lesson 2’s panic hook makes sure you hear about it instead of getting a
silent unreachable.
One parser, two notations
Players type Nf3; a click-driven UI produces g1f3. try_make_move
accepts both by trying SAN first and falling through to UCI:
pub fn try_make_move(&mut self, uci: &str) -> Result<String, String> {
let input = uci.trim();
if let Ok(san) = input.parse::<San>() {
if let Ok(m) = san.to_move(&self.board) {
if self.board.is_legal(m) {
return Ok(self.play_move(m));
}
}
}
// ...UCI fallback below
The UCI path parses the from/to squares, filters legal_moves() down to
matches, and then deals with the one genuinely tricky case. When a pawn
reaches the last rank, e7e8 is ambiguous — four different moves share
that from/to pair. The resolution:
let chosen = match promo {
Some(role) => matches.iter().copied().find(|m| m.promotion() == Some(role)),
None => matches.iter().copied()
.find(|m| m.promotion().is_none() || m.promotion() == Some(Role::Queen))
.or_else(|| matches.first().copied()),
};
An explicit suffix (e7e8n) is honored; a bare e7e8 promotes to queen.
That default is a UI decision made in the engine: lesson 7’s click
interaction sends bare from/to strings, and players expect a queen without
being asked. Anything that parses as neither notation, or parses but isn’t
legal, returns Err("Invalid move") and leaves the board untouched.
Loading a position that might be garbage
Persistence (lesson 9) and tests both need to load arbitrary positions, so
the last method this lesson is try_set_fen. shakmaty splits FEN handling
into two stages, and keeping both is the point:
let parsed: Fen = fen.parse().map_err(|_| "Invalid FEN".to_string())?;
let pos = parsed
.into_position::<Chess>(shakmaty::CastlingMode::Standard)
.map_err(|_| "Invalid FEN".to_string())?;
parse rejects strings that aren’t FEN-shaped; into_position rejects
FENs that describe impossible chess (lesson 10 feeds it a board with two
white kings to prove it). Loading a FEN also clears the history and last
move — a loaded position is a fresh start, a fact lesson 8 will turn out
to depend on in a way worth watching for.
Five new tests pin all of this down: SAN moves, UCI moves, a rejection grab-bag (illegal SAN, illegal UCI, garbage, empty string — and the board unchanged after all four), invalid FENs, and the three promotion spellings.
Build it
| File | Action | What goes in it |
|---|---|---|
chess-engine/src/lib.rs |
modify | Two new struct fields; the plain impl block with play_move, try_make_move, try_set_fen; five new tests |
chess-engine/pkg/ |
generated | Rebuild with npm run build:wasm, delete the dropped .gitignore, commit |
Done when: npm run test:rust reports 8 passed.
Answer key: lesson-02...lesson-03.
Challenge: the fourth promotion
The promotion test covers queen-by-default, knight-by-suffix, and
rook-by-SAN. Add the bishop case in both notations, then check the parser
code to see whether a suffix like e7e8x falls through to queen or to
rejection — and decide whether you agree with what you find.
Next lesson, the boundary opens: the whole API gets exported to JavaScript,
and the error strings you’ve been returning learn to cross into a catch
block.