[ 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.
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
- No JS, whole game. The server-rendered state is the final position with the full score printed — kill JavaScript and you still get mate and all seventeen moves.
- Reduced motion is the same answer. Under
prefers-reduced-motionthe scroll driver never attaches, the track collapses to normal height, transitions are off, and the board is one static frame: the final position, full notation beside it. The controls still work — they just jump. - The teleport class. On mount the board rewinds from the prerendered final
position to move zero; a one-frame
transitions: noneclass stops 32 pieces from streaking across the board on page load. - One set of piece paths. The six piece types are drawn once as SVG symbols;
colour arrives through CSS custom properties, which inherit through
<use>shadow trees — so the same paths serve both sides and both themes. - Scroll math is cheap. One passive scroll listener, throttled by
requestAnimationFrame, doing a rectangle read and a division. There is no per-frame loop anywhere on the page. - Autoplay yields to humans. A wheel or touch cancels the play timer immediately — the cinema stops the moment you reach for the board.
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.