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.