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?”