The board becomes a game you can (half) play: click a piece, see where it
may go, click a destination, watch the position change. The lesson ends
with the app in a deliberately broken-feeling state, and the reason why is
the setup for lesson 8. Checkpoint:
lesson-07.
Select, then move — and let Rust say no
Click handling is a two-phase state machine on one nullable field. No selection yet? Select the clicked piece if it’s yours. Already selected? Build a UCI string and try it:
const uci = `${fromFile}${8 - fromRow}${toFile}${8 - row}`;
try {
this.game.make_move(uci);
this.selectedSquare = null;
this.updateGameState();
} catch (e) {
// Invalid move, deselect
this.selectedSquare = null;
}
Look at what’s missing: no legality check in TypeScript. The component
builds a candidate string and throws it over the boundary; Rust either
plays it or throws, and an illegal destination just deselects. Every
alternative means duplicating chess rules in a second language — the bug
farm this architecture exists to avoid. Note the payoff of lesson 3’s
promotion default, too: a click can only ever say e7e8, and the engine
quietly gives the player their queen.
The guard at the top of the handler reads
if (!this.game || !this.isHumanTurn || this.gameOver) return; — remember
it; it’s about to star in this lesson’s ending.
Highlights are questions, not knowledge
Selection and hover both light up legal destinations, and both are powered by asking, not knowing:
private movesForSquare(square: number): number[] {
if (!this.game) return [];
const file = this.fileNames[square % 8];
const rank = 8 - Math.floor(square / 8);
const targets = this.game.get_moves_from(`${file}${rank}`);
if (!targets) return [];
return targets.split(',').map(t => this.squareToIndex(t));
}
get_moves_from returns an empty string for an empty square or an
opponent’s piece, so hover preview on enemy pieces costs nothing to
implement correctly. Hover ignores touch pointers
(e.pointerType === 'touch' — a finger tap shouldn’t leave phantom
highlights), and when both exist, hover targets win over selection targets.
The CSS gives destinations a centered dot, except when the target square
holds a piece — then :has() turns the dot into a capture ring:
.square.valid-move:has(.piece)::after {
inset: 3px;
background: none;
border: 4px solid oklch(20% 0.02 var(--hue) / 0.3);
}
Last-move marks work the same way as everything else: applyGameState now
also reads get_last_move() and is_human_turn(), and the render loop
tints the from/to squares. There’s also a new one-line method,
updateGameState(), that just calls applyGameState() — it looks
pointless today, and lesson 13 will replace its body with the animation
machinery. Route every mutation through it now and the wow layer later
lands as a diff to one method.
The freeze is correct
Play 1. e4. The pawn moves, the marks appear — and the board stops
responding. Nothing is broken: it’s black’s turn, is_human_turn() is
false, and the guard you wrote is doing its job. The game is politely
waiting for an opponent who doesn’t exist yet.
That’s the lesson boundary, and it’s a real architectural moment: turn management already works end to end. What’s missing is a player for the other side.
Build it
| File | Action | What goes in it |
|---|---|---|
src/chess-board.ts |
modify | New state fields (selectedSquare, hoverSquare, lastMove, isHumanTurn, gameOver); handleSquareClick, movesForSquare, squareToIndex, isLastMove, handleSquareHover, updateGameState; extend applyGameState and the render loop; CSS for .selected, .last-move, .valid-move |
Done when: clicking e2 shows dots on e3 and e4, playing 1. e4 marks both squares and then the board ignores every further click.
Answer key: lesson-06...lesson-07.
Challenge: tell the two marks apart
Both ends of the last move get the same tint. Style the origin square
differently from the destination (isLastMove already knows which is
which — split it), and decide by playing a few moves whether the
distinction earns its visual noise.
Next lesson, the opponent: twenty lines of Rust with a fake random number generator, and an honest accounting of exactly how little they buy.