Dice that actually tumble: the new 3D roll
The board dice are now real 3D cubes that wind up, tumble, and land on your numbers — no fake delay, and gentle on reduced-motion.

In plain words
Rolling used to be flat: the numbers just flipped over. Now the two movement dice — and the Speed die — are real 3D cubes that crouch, leap, and tumble through the air before settling face-up on the table.
Here is the nice part: the dice keep spinning only while the game works out your move, then drop and land on the exact numbers you rolled. There is no padded, fake pause — the moment your result is ready, they fall. On a quick connection the whole thing is over in a heartbeat.
- A quick wind-up the instant you tap Roll, so it feels responsive right away.
- A real tumble through the air while your move is computed — it stretches naturally if the server is slow, instead of showing a placeholder.
- A smooth landing onto your actual numbers, with your token starting to move the moment the dice settle.
- The Speed die joins in too, and still shows a quiet "?" between rolls so you always know it is in play.

It is a purely visual change — the rules, the odds, and the board all play exactly as before. The dice just finally feel like dice.
For the technically curious
Each die is a six-faced 3D CSS cube driven by a small state machine that walks idle → windup → loop → settle → jitter → landed. Orientation is stored as a quaternion so we can rotate around any axis and convert to a single CSS rotate3d() per frame; FACE_Q maps each value 1–6 to the orientation that brings that pip face forward.
// apps/web/app/[locale]/game/[id]/dice3d.ts
// quaternion per face → one CSS rotate3d() at rest
const FACE_Q: Record<DieValue, Quat> = {
1: Q.identity,
2: Q.axis([0, 1, 0], +Math.PI / 2),
3: Q.axis([0, 1, 0], -Math.PI / 2),
4: Q.axis([1, 0, 0], +Math.PI / 2),
5: Q.axis([1, 0, 0], -Math.PI / 2),
6: Q.axis([1, 0, 0], Math.PI),
};The roll is optimistic: a tap bumps an optimistic nonce, kicking off the 180ms windup and an indefinite tumble loop. When the authoritative roll arrives (a rollSeq bump), the spinning die settles immediately along the shortest forward rotation onto FACE_Q[value] and falls — there is no minimum-spin floor, so a fast response lands a fast roll.
// authoritative values arrive → settle now, no spin floor
onRollSeq(values) {
values.forEach((v, i) => {
if (controller[i].isSpinning) controller[i].setResult(v, arrival + i * 78);
else controller[i].startRoll(arrival), controller[i].setResult(v, /* after windup */);
});
}By owner decision the handoff’s decaying landing bounces were dropped (restitution 0), so the settle is one smooth accelerating descent that touches down exactly on the value, leaving only a short damped wobble. One shared rAF loop runs only while a die is animating, every phase uses absolute performance.now() timestamps (so a backgrounded tab fast-forwards correctly), and a backstop settles to the current store values if no roll arrives. Reduced motion skips the optimistic nonce entirely and just swaps faces. Web-only: engine, daemon, and the database are untouched.
