← Back to Endgame

[ Parable build guide ]

How Endgame was built

An online chess club whose showpiece is a famous game scrubbed by scroll — the Opera Game of 1858, replayed move by move as the page moves, with the notation ticking alongside and captions surfacing on the sacrifices. No chess engine, no animation library: precomputed snapshots and one CSS transition.

SvelteKit + adapter-staticSVG board, zero imagesCormorant / Public Sans / JetBrains Monolight + darkreduced-motion honest

The idea

Chess clubs sell a feeling older than the internet: sitting beside someone stronger while they replay a game and tell you where it turned. A static diagram can't do that, and a video does it to you rather than with you. Scroll is the right verb — you set the tempo, you back up when the queen sacrifice doesn't make sense, and the page never moves on without you. So the club's whole pitch ("study the classics until they're yours") is also the page's signature interaction.

The stack

SvelteKit with adapter-static, fully prerendered — no server, no fallback page, output straight into docs/ for GitHub Pages. Svelte earns its keep here: the board position is a single $derived value, so scrubbing, stepping and autoplay are all the same one-line state change. Type is Cormorant (a bookish display serif for a club that reveres its classics), Public Sans for body copy, and JetBrains Mono for the language chess already writes in monospace: notation, ratings, clock times.

Signature technique — the scroll-scrubbed replay

The trick is to make the game data, not procedure. Each half-move is a tiny diff — which piece moves where, what gets captured, what the caption should say — and the whole game is folded once, up front, into 34 position snapshots:

// every ply is data: what moves where, what dies, what to say
{ san: 'Qb8+!!', ops: [['wQ', 'b8']],
  note: 'The queen sacrifice. Black must take…' },
{ san: 'Nxb8',   ops: [['bNg', 'b8']], x: 'wQ' },
{ san: 'Rd8#',   ops: [['wRh', 'd8']],
  note: 'Mate — a rook and a bishop, the only two pieces…' }

// …and the whole game folds into 34 snapshots, once, up front
export function buildStates() {
  const pos = {};
  START.forEach((p) => (pos[p.id] = { sq: p.sq, dead: false }));
  const states = [snapshot(pos)];
  for (const ply of PLIES) {
    if (ply.x) pos[ply.x] = { sq: pos[ply.x].sq, dead: true };
    for (const [id, to] of ply.ops) pos[id] = { sq: to, dead: false };
    states.push(snapshot(pos));
  }
  return states;
}

Captured pieces keep the square they died on with a dead flag, so they fade in place — and fade back when you scroll uphill. Snapshots make the replay perfectly reversible: any scroll position maps to a ply, and jumping between plies is just handing the board a different snapshot.

// scroll is the only clock: progress through the tall
// track picks the ply; the pieces do the rest in CSS
const rect = track.getBoundingClientRect();
const total = track.offsetHeight - window.innerHeight;
const p = Math.min(1, Math.max(0, -rect.top / total));
const k = Math.round(p * N);
if (k !== ply) ply = k;

// a piece is just a keyed SVG group; moving it is a style change
<g class="pieceg" style="transform: translate({x}px, {y}px)">
  <use href="#pc-{type}" />
</g>

/* app.css — the actual animation */
.pieceg { transition: transform .55s var(--ease); }

Because each piece is a keyed SVG group whose position is a CSS transform, "animation" is nothing more than the browser transitioning that transform — the knight glides to b5, the queen slides into b8, and fast scrubbing settles gracefully instead of stuttering. The play button and step buttons never touch the ply directly; they scroll the page to the offset that means that ply, so the scrollbar, the board and the notation ticker can never disagree.

Details that matter

The sign-in form is a demo — it validates and confirms in place but sends nothing anywhere. Wire it to your real auth (an identity provider, your own endpoint) before seating actual members.

Ship it on GitHub Pages

The whole site prerenders, so deployment is a folder. adapter-static writes into docs/ and the base path handles the repo subdirectory:

import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    // prerender straight into /docs for GitHub Pages
    adapter: adapter({ pages: 'docs', assets: 'docs', fallback: null }),
    // every link + asset gets the repo prefix in production
    paths: { base: process.env.NODE_ENV === 'production' ? '/endgame' : '' }
  }
};
npm run build              # → docs/index.html + docs/guide/index.html
git add -A && git commit -m "Endgame — online chess club"
gh repo create bswxyz/endgame --public --source . --push
# GitHub → Settings → Pages → Branch: main · Folder: /docs
# live at https://bswxyz.github.io/endgame/

A .nojekyll file in static/ keeps GitHub from re-processing the output. That's the entire pipeline: push, point Pages at /docs, done.

← Back to Endgame