How it’s built
A D20 from scratch
No 3D library, no canvas, no WebGL. Just geometry, a bit of quaternion math, and SVG. The die drifts on its own until you click it; then it decides the result first and tumbles its way there. Here is the whole thing, step by step.
The icosahedron
A D20 is an icosahedron*. To build one you only need 12 points, and there is a beautiful shortcut to find them: they are the corners of three rectangles that cross each other at right angles, each one 2 units by 2φ, where φ is the golden ratio**.
const PHI = (1 + Math.sqrt(5)) / 2
const RAW: Vec[] = [
[-1, PHI, 0], [1, PHI, 0], [-1, -PHI, 0], [1, -PHI, 0],
[0, -1, PHI], [0, 1, PHI], [0, -1, -PHI], [0, 1, -PHI],
[PHI, 0, -1], [PHI, 0, 1], [-PHI, 0, -1], [-PHI, 0, 1],
]
// Divide each vertex by its length: all 12 now sit on a sphere of radius 1
const VERTS = RAW.map((v) => scale(v, 1 / Math.hypot(...v)))Those raw points are a bit far from the center (about 1.9 units). Dividing each one by its own length pushes all of them onto a sphere of radius 1***.
Finding the 20 faces
Rather than typing the faces by hand, the code finds them. A face is any three vertices that are all exactly one edge apart from each other. Checking every combination of 3 out of 12 points yields exactly 20 triangles.
const edge = Math.min(...VERTS.slice(1).map((v) => dist(VERTS[0], v)))
const near = (a, b) => Math.abs(dist(VERTS[a], VERTS[b]) - edge) < 1e-6
for (let a = 0; a < 12; a++)
for (let b = a + 1; b < 12; b++)
for (let c = b + 1; c < 12; c++)
if (near(a, b) && near(b, c) && near(a, c)) faces.push([a, b, c])For each face it also keeps three things for later: its normal*, its centroid (the middle of the triangle, where the number goes), and an “up” direction pointing from the centroid to the first vertex, so each numeral knows which way to stand.
Numbering like a real D20
On a real D20, opposite faces always add up to 21. For each face the code looks for the one whose normal points the exact opposite way (their dot product is −1) and numbers the pair 1 and 20, then 2 and 19, and so on. The 6 and the 9 are underlined so they can’t be confused.
NORMALS.forEach((n, i) => {
if (numbers[i]) return
const opposite = NORMALS.findIndex((m) => dot(n, m) < -0.999)
numbers[i] = next
numbers[opposite] = 21 - next
next++
})Rotation with quaternions
The die’s orientation is stored as a quaternion*, not as three angles. Three angles (turn around X, then Y, then Z) are intuitive, but they suffer from gimbal lock**, and there is no clean way to blend between two sets of them. Quaternions solve both problems, and they make a slerp*** possible: the smooth transition the roll relies on.
The four quaternion functions
axisAngle(axis, angle) creates a rotation. It uses half the angle, because a quaternion rotates a vector by applying itself twice.
function axisAngle(axis: Vec, angle: number): Quat {
const [x, y, z] = normalize(axis)
const s = Math.sin(angle / 2)
return [Math.cos(angle / 2), x * s, y * s, z * s]
}mul(a, b) chains two rotations into one (the Hamilton product). Order matters: mul(a, b) means “first b, then a”, just like turning a real object twice.
function mul(a: Quat, b: Quat): Quat {
return [
a[0] * b[0] - a[1] * b[1] - a[2] * b[2] - a[3] * b[3],
a[0] * b[1] + a[1] * b[0] + a[2] * b[3] - a[3] * b[2],
a[0] * b[2] - a[1] * b[3] + a[2] * b[0] + a[3] * b[1],
a[0] * b[3] + a[1] * b[2] - a[2] * b[1] + a[3] * b[0],
]
}rotate(q, v) applies a rotation to a point. The textbook formula is q · v · q⁻¹, but it expands into this shorter version with two cross products, which is all the die needs 12 times per frame.
function rotate(q: Quat, v: Vec): Vec {
const u: Vec = [q[1], q[2], q[3]]
const t = scale(cross(u, v), 2)
return add(add(v, scale(t, q[0])), cross(u, t))
}slerp(a, b, t) blends two orientations. The dot product measures how far apart they are. If it is negative, one of them is flipped, because q and −q describe the same rotation and this way it takes the short route. When both are almost identical, a straight blend is enough and avoids dividing by zero.
function slerp(a: Quat, b: Quat, t: number): Quat {
let d = dot4(a, b)
// Take the short way round
if (d < 0) { b = negate(b); d = -d }
// Almost equal: a straight line is fine
if (d > 0.9995) return lerp(a, b, t)
// Angle between both orientations
const th = Math.acos(d)
const wa = Math.sin((1 - t) * th) / Math.sin(th)
const wb = Math.sin(t * th) / Math.sin(th)
return a.map((v, i) => v * wa + b[i] * wb)
}Drawing it in SVG
Every frame, the 12 vertices are rotated with the current quaternion and flattened onto the screen with an orthographic projection*: keep x and y, drop z, and flip y because SVG counts downwards. Then comes back-face culling**: only faces whose normal points towards the viewer are drawn. Each one is a black <polygon> with a violet stroke, and a non-scaling stroke*** keeps the lines crisp.
const projected = VERTS.map((v) => rotate(orientation, v))
const visible = FACES
.map((f, i) => ({ f, z: rotate(orientation, NORMALS[i])[2] }))
.filter(({ z }) => z > 0) // facing the viewer
<polygon
points="x1,-y1 x2,-y2 x3,-y3"
fill="#000"
stroke="#8b5cf6"
vectorEffect="non-scaling-stroke"
/>Numbers that stick to the faces
This is the neat part. Under orthographic projection, anything flat on a face reaches the screen through an affine transform*, and SVG can apply one with a single attribute: transform="matrix(a b c d e f)", which maps a point (x, y) to (a·x + c·y + e, b·x + d·y + f).
So each numeral is drawn flat at the origin, and the matrix says where its axes go: (a, b) is where the numeral’s “right” lands on screen, (c, d) is where its “down” lands (minus “up”, since SVG’s y grows downwards), and (e, f) is the face’s center. Those are just the face’s own vectors after rotation.
function faceMatrix(q: Quat, i: number) {
const u = rotate(q, UPS[i]) // the numeral's "up"
const r = rotate(q, cross(UPS[i], NORMALS[i])) // the numeral's "right"
const c = rotate(q, CENTROIDS[i]) // the face's center
return `matrix(${r[0]} ${-r[1]} ${-u[0]} ${u[1]} ${c[0]} ${-c[1]})`
}That is why the side numbers look squashed and tilted, exactly like the ones on a physical die. The numerals use Single Day, in the same violet as the edges.
The roll
The result is picked before anything moves: a random face from 0 to 19. Then the code works out the orientation that shows that face to you in two moves: tilt the face until its normal points straight at the screen, then spin it around that axis until the numeral is upright, using atan2 to measure how far off it is.
function faceUp(i: number): Quat {
const n = NORMALS[i]
const z: Vec = [0, 0, 1]
// 1. Turn the face's normal onto +z
const align = axisAngle(cross(n, z), Math.acos(dot(n, z)))
// 2. Spin around z until the numeral stands upright
const up = rotate(align, UPS[i])
return mul(axisAngle(z, Math.atan2(up[0], up[1])), align)
}The animation lasts 1.8 seconds and combines two motions. A slerp carries the die from where it is to where it has to land. On top of that, an extra tumble of 2 to 4 full turns around a random axis makes it feel thrown. The trick is that the tumble unwinds as the animation advances: it is multiplied by (1 − e), so it reaches exactly zero at the end and the die lands precisely on the chosen face.
const face = Math.floor(Math.random() * 20) // the result is decided first
const to = faceUp(face)
const axis = randomUnitVector()
const turns = (2 + Math.floor(Math.random() * 3)) * Math.PI * 2
const tick = (now) => {
const t = Math.min((now - start) / 1800, 1)
const e = 1 - Math.pow(1 - t, 4) // ease-out
q = mul(axisAngle(axis, turns * (1 - e)), slerp(from, to, e))
if (t < 1) requestAnimationFrame(tick)
}The easing* is a quartic ease-out: fast at first and slowing down gently, like a die losing energy on a table.
Drifting at rest
Before the first roll, the die turns slowly on its own. Each frame it rotates a little, proportional to the time since the previous frame, so it spins at the same speed on a 60Hz or a 120Hz screen.
const tick = (now) => {
const dt = (now - last) / 1000 // seconds since the last frame
last = now
q = mul(axisAngle([0.3, 1, 0.15], dt * 0.35), q)
idleFrame.current = requestAnimationFrame(tick)
}A lesson from building it: the drift and the roll each keep their own animation frame reference. When they shared one, starting a roll re-ran the drift’s cleanup, which cancelled the roll’s frame and left the die stuck on “Rolling…”.
Accessibility
The die is a real <button>, so it rolls with a click, Enter or Space and has a visible focus ring. The SVG is hidden from screen readers, and the result is announced through a live region (“Rolled 17”). With reduced motion turned on, the die stays still and a roll jumps straight to the result.