[ Parable build guide ]
How Meeplewood was built
A board-game publisher whose hero is an honest-to-goodness d20 built out of
twenty <div>s — icosahedron math, one matrix3d per face,
rolled with quaternions, settled with an overshoot ease. Plus a rulebook that flips like paper.
No 3D library anywhere.
The idea
Every game night starts with the same argument: what do we play? So the site opens by settling it — a clickable d20 that tumbles, lands, and tells you which box to pull off the shelf. The second signature follows the same logic. A publisher that brags about "kind rulebooks" should prove it, so the how-to-play section is the rulebook: four spreads you flip through in CSS 3D, short enough to time yourself reading.
The stack
SvelteKit with @sveltejs/adapter-static, fully prerendered — no server, no fallback
page, every route is plain HTML the moment it loads. Svelte 5 runes ($state, $derived) drive the die's orientation and the book's spread index. The build lands in docs/ so GitHub Pages can serve it straight off the main branch. Type is Fredoka for
display (round, friendly, slightly toy-like), Nunito for body, and Space Mono for the table-talk
metadata — player counts, teach times, page numbers.
Signature technique — a d20 from twenty divs
An icosahedron's 12 vertices are just the corners of three mutually-perpendicular golden
rectangles: (0, ±1, ±φ) and its two rotations. Its 20 faces are every triple of
vertices exactly one edge apart — cheap to find by brute force at init. Each face then needs a
single CSS transform that lays a flat triangle onto the solid, which is one change-of-basis
matrix: local x along the triangle's base, local y from apex to centroid, z the outward
normal, translation to the centroid.
// each face of the d20 is one <div>, laid onto the solid by hand.
// icosahedron vertices come from three golden rectangles; faces are
// every triple of vertices exactly one edge apart. Then, per face:
const G = centroid(A, B, C);
let n = norm(cross(sub(B, A), sub(C, A))); // outward normal -> CSS z
if (dot(n, G) < 0) n = neg(n);
let u = norm(sub(B, C)); // local x — along the base
const v = norm(sub(G, A)); // local y — apex to centroid
if (dot(cross(u, v), n) < 0) u = neg(u); // keep the number unmirrored
face.transform = `matrix3d(
${u[0]}, ${u[1]}, ${u[2]}, 0,
${v[0]}, ${v[1]}, ${v[2]}, 0,
${n[0]}, ${n[1]}, ${n[2]}, 0,
${G[0]}, ${G[1]}, ${G[2]}, 1)`; Each face element is a clip-path triangle whose transform-origin sits
on the triangle's centroid (50% 66.667%), so the matrix drops it exactly into place
inside a preserve-3d stage. The stage itself carries one rotation — a quaternion,
rendered to CSS as rotate3d().
// rolling = two phases on one quaternion.
// A: a free tumble around a random axis, decelerating…
qMid = qMul(qAxis(axisA, spin * (1 - Math.pow(1 - p, 2.2))), q0);
// B: …then slerp into the face the roll picked, with the house
// "settle" ease — it overshoots slightly, like a real die's last wobble.
orient = qSlerp(qMid, target, easeSettle(p)); Because the roll picks its face first, the suggestion is honest: the die aligns that
face's normal to the viewer (plus a random readable twist), and antipodal faces are numbered to
sum to 21, like a real d20. Faces are relit every frame with a fixed light direction — brightness = max(0, n·L) — so the facets shade correctly as it tumbles.
Details that matter
- The idle wobble is cheap. ~30fps, two small sine oscillations, and an
IntersectionObserverstops it entirely the moment the die scrolls off-screen. - Reduced motion keeps the feature. Under
prefers-reduced-motionthere is no tumble and no wobble — one static frame — but clicking still snaps to a face and still answers "what do we play?". The page-flip becomes an instant page-swap the same way. - Nothing hides without JavaScript. The face matrices are computed at component
init, so the prerendered HTML ships a fully-formed 3D die. Reveals and the hero's clipped lines
are gated behind a
.jsclass set synchronously in<head>. - The book cheats with z-index, honestly. Three sheets over two base pages make
four spreads; whichever sheet is mid-flip borrows
z-index: 40for 900ms so it never clips through its neighbours. - One ease to rule them. The house curve
cubic-bezier(.26, 1.5, .38, .96)— "settle" — overshoots and rests, like a die's last wobble. Buttons, reveals and the roll's final slerp all share it. - Theme is one attribute. Light linen / dark board-night flip on
:root[data-theme], persisted tolocalStorage, bootstrapped inline in<head>before first paint. The box art keeps fixed print colours so the "shelf" looks like ink, not UI.
The RSVP form is a demo — it validates and confirms in place but sends nothing. Wire it to a real endpoint (Formspree, a serverless function, your shop's mailer) before promising anyone a chair.
Ship it on GitHub Pages
The adapter writes the whole prerendered site into docs/, and the base path flips to
the repo name for production builds:
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({ pages: 'docs', assets: 'docs', fallback: null }),
paths: {
base: process.env.NODE_ENV === 'production' ? '/meeplewood' : ''
}
}
}; npm run build # prerenders every route into docs/
git add docs && git commit -m "build"
git push # then: Settings -> Pages -> main /docs A .nojekyll file rides along in static/ so Pages serves the _app directory untouched. Internal links go through SvelteKit's base helper, so the same build works at the domain root or a project subpath.