From FEN to Pixels
Build a Chess Game with Rust and WebAssembly
One component owns the whole interface, and this lesson builds its first
working version: a rendered chessboard, correct pieces, coordinates on the
edges, and a design-token system the polish lesson will cash in later.
No interaction yet — that’s next lesson’s job. Checkpoint:
lesson-06.
A component whose state is someone else’s
The Lit class declares its reactive fields with decorators (the tsconfig flags from lesson 1 finally earn their keep):
@customElement('chess-board')
export class ChessBoard extends LitElement {
@property({ type: Boolean }) playerIsWhite = true;
@state() private game: ChessGame | null = null;
@state() private fen = '';
@state() private isLoading = true;
The architecture rule to internalize: the wasm object is the source of
truth, and @state fields are a projection of it. After anything changes
the game, one method re-reads the engine:
private applyGameState() {
if (!this.game) return;
this.fen = this.game.get_fen();
}
One field today, eight by lesson 9 — but always the same shape: read everything back, never patch component state by hand. Two sources of truth about a chess position is how UIs end up showing a queen the engine already captured.
Loading happens in firstUpdated(), with the engine imported dynamically
so Vite code-splits the glue, wrapped in try/catch that downgrades to a
console error rather than a white page.
Sixty-four squares from one string
parseFen expands the placement field (rnbqkbnr/pppppppp/8/...) into an
8×8 array of glyphs from one lookup string:
private pieceChars = '♙♘♗♖♕♔♟♞♝♜♛♚';
The glyph does double duty. Its index tells you the color — first six are
white — which is how pieceName() labels squares and how piece color is
derived from the glyph, not the FEN letter case. Rendering is a nested
map producing a div.square per cell, with rank numbers down the first
column and file letters along the bottom row, both aria-hidden since
aria-label on occupied squares already names the piece properly. That
data-piece attribute is doing silent work: it labels squares for
accessibility today, becomes hover tooltips in lesson 13, and gives the
test suites their most readable selectors.
Tokens now, wow later
The component’s stylesheet opens with a block worth typing slowly:
:host {
--hue: 255;
--board-hue: 75;
--surface: light-dark(oklch(97% 0.008 var(--hue)), oklch(21% 0.015 var(--hue)));
--text: light-dark(oklch(25% 0.02 var(--hue)), oklch(92% 0.01 var(--hue)));
--brand: oklch(60% 0.17 var(--hue));
--sq-light: light-dark(oklch(89% 0.045 var(--board-hue)), oklch(68% 0.05 var(--board-hue)));
color-scheme: light dark;
}
Two hue numbers; every other color derives from them with light-dark(),
oklch(), and color-mix(). Dark mode already works — flip your OS theme
and watch — with no JavaScript, no toggle, no second stylesheet. Lesson 13
tours the derivation tricks properly; today you copy the token block and
get on with the board, which is a grid of squares sized by
--sq: clamp(40px, 10.5cqi, 62px) so it scales with its container.
index.html swaps its placeholder heading for <chess-board>, plus a few
body styles to center the stage. src/main.ts shrinks to its final form:
one side-effect import, one line.
Build it
| File | Action | What goes in it |
|---|---|---|
src/chess-board.ts |
write | The component: token block, board/square/coord/piece CSS, state fields, firstUpdated init, applyGameState, parseFen, pieceName, the render loop |
src/main.ts |
write | import './chess-board'; and nothing else |
index.html |
modify | <chess-board> element, centered-stage body styles |
package.json |
modify | Add lit to dependencies |
Done when: the dev server shows a chessboard with 32 pieces and edge coordinates, and it restyles itself when you switch your OS between light and dark.
Answer key: lesson-05...lesson-06.
Challenge: a second board
Drop a second <chess-board> next to the first in index.html. Two
independent games appear — shadow DOM keeps the styles from colliding and
each component owns its own engine instance. Cheap to try, and it makes the
“one component, self-contained” claim concrete before you delete it.
Next lesson the board starts listening: selection, legal-move dots, and a first move that leaves the game mysteriously frozen.