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.