Close the tab mid-game, come back tomorrow, and the position, the turn, and the move list are all still there. This lesson buys that with two localStorage keys, then rounds out the panel: a New Game button, the move history with figurine notation, and a help drawer that doesn’t pause the game. Checkpoint: lesson-09.

Persistence is two strings

Lesson 4’s API was designed for this moment. The complete save format is the FEN (which round-trips castling rights and en passant, because get_fen serializes with EnPassantMode::Legal) plus the SAN history — and applyGameState already runs after every change, so persisting is two lines at the end of it:

localStorage.setItem('chess-fen', this.fen);
localStorage.setItem('chess-history', this.history);

Restore happens in firstUpdated, and the interesting part is the failure path:

const savedFen = localStorage.getItem('chess-fen');
if (savedFen) {
  try {
    game.set_fen(savedFen);
    game.set_history(localStorage.getItem('chess-history') ?? '');
  } catch {
    // Corrupt save (e.g. from an older version) — start fresh.
    game.reset();
  }
}

set_fen throws on garbage — that’s lesson 3’s two-stage validation working across the boundary — so a corrupt save from an old version or a curious DevTools user degrades to a fresh game instead of a dead page. And because firstUpdated already ends with scheduleCpuMove(), a game saved on the computer’s turn resumes itself: reload, 300 ms, reply. That composition wasn’t planned by this lesson; it falls out of routing everything through the same two methods.

New Game is the inverse: reset(), drop both keys, updateGameState().

The history panel earns its subgrid

get_history() returns "e4, Nf6, Nc3"; the panel renders it as numbered pairs the way every chess book does. Pairing is a loop stepping by two; the typographic touch is one small method:

private figurine(san: string): string {
  const map: Record<string, string> = { K: '♚', Q: '♛', R: '♜', B: '♝', N: '♞' };
  return san.replace(/[KQRBN]/g, c => map[c]);
}

Nf3 renders as ♞f3. The layout is a three-column grid where each row is grid-template-columns: subgrid, so move numbers, white’s column, and black’s column stay aligned across every row without a table. The newest move gets a .latest tint, and a small updated() lifecycle hook scrolls the panel to the bottom whenever the history changes — querying the shadow root via this.renderRoot, which is the piece of Lit plumbing this lesson adds to your vocabulary.

A dialog that refuses to be modal

The Help button opens a <dialog> — but with .show(), not .showModal(). Non-modal means no backdrop, no focus trap: the game stays fully playable while the rules sit open beside it, which is exactly what a beginner mid-game wants. The drawer’s content is plain markup — the goal, how pieces move, the special moves, how games end, how to read the move list — worth writing carefully since it’s the only teaching surface the app itself has.

Non-modal dialogs come with one paper cut: the platform only wires Escape-to-close for modal ones. So the component adds a keydown listener in connectedCallback, removes it in disconnectedCallback, and that pair of lifecycle methods is the lesson’s second piece of Lit plumbing — listeners on window must be cleaned up by hand or they outlive the element.

Build it

File Action What goes in it
src/chess-board.ts modify Persist + restore + corrupt-save recovery; history state and its read; New Game and Help buttons; figurine, renderHistory, renderHelpDialog; toggleHelp, handleEscape, the lifecycle pair, the updated() auto-scroll; CSS for actions, history, buttons, and the drawer

Done when: play two moves, reload, and the position, turn, and history survive; New Game returns everything to the start.

Answer key: lesson-08...lesson-09.

Challenge: export the game

Add a button to the panel that downloads the move list as a .txt in PGN move-text style (1. e4 Nf6 2. ...) using a Blob and a temporary anchor. Everything you need is already in this.history — the exercise is turning component state into a file without a server.

Next lesson goes back to Rust and asks the harder testing question: not “does it work?” but “which fixtures would catch it lying?”