[ 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.

SvelteKit + adapter-staticSvelte 5 runesCSS 3D · no librariesFredoka / Nunito / Space Monolight + darkreduced-motion aware

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 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.

← Back to Meeplewood