DEV Community

Joe Lin for BeGoodTool.com

Posted on

Why a tumbling dice animation has to know the result before it ever starts spinning

A while back I set out to build a simple "roll N dice" page — pick a quantity, hit a button, get a total. The random-number part is trivial, one line: Math.floor(Math.random() * 6) + 1 in a loop. What actually took thought was making the dice look like they're tumbling in pure CSS, with no physics engine and no WebGL. My first assumption was that most dice sites spin the cube for a bit and then swap the top face to whatever number they rolled, the way a slot machine reel fakes a stop. Reading through how this one actually works, that's not what happens at all — the roll happens first, and the animation is just catching up to an answer that already exists.

A die is six divs pretending to be a cube

Before any rotation logic, the cube itself is just six absolutely-positioned .face elements, each rotated and pushed out along the Z axis so they form the sides of a box:

.face-one {
  transform: translateZ(0.5em);
}
.face-two {
  transform: rotateY(90deg) translateZ(0.5em);
}
.face-six {
  transform: rotateX(-180deg) translateZ(0.5em);
}
Enter fullscreen mode Exit fullscreen mode

translateZ(0.5em) pushes a face half a cube-width outward from the shared center, and the rotateX/rotateY before it decides which direction "outward" points. Do that six times with the right rotation for each face and you get a hollow cube, held together with transform-style: preserve-3d on the parent. There are also three unlabeled .filler divs tucked into the corners — the real faces have border-radius, so rotating the cube exposes rounded-corner gaps at the seams, and the fillers are flat, borderless patches sitting just behind them to hide the see-through cracks.

The roll happens before the cube moves at all

Here's the part that surprised me. rollDice() doesn't animate toward a random destination — it rolls the dice with Math.random() first, and only then works out what rotation would visually display that already-decided number:

const rollDice = () => {
  if (data.rolling) return;
  data.rollDegreeList = data.reslutFace.map(() => ({ x: 0, y: 0, z: 0 }));

  setTimeout(() => {
    data.rolling = true;
    let temp_counts = [0, 0, 0, 0, 0, 0];

    data.reslutFace = data.reslutFace.map(() => {
      let newResult = Math.floor(Math.random() * 6) + 1;
      temp_counts[newResult - 1] += 1;
      return newResult;
    });

    data.rollDegreeList = data.reslutFace.map((reslut) => {
      const randomNum = Math.floor(Math.random() * 6) + 5;
      switch (reslut) {
        case 1:
          return { x: 360 * randomNum, y: 360 * randomNum, z: 360 * randomNum };
        case 6:
          return { x: 360 * randomNum + 180, y: 360 * randomNum, z: 360 * randomNum };
        // ...cases 2–5 follow the same shape
      }
    });

    setTimeout(() => {
      data.rolling = false;
      data.counts = temp_counts;
    }, 2300);
  }, 100);
};
Enter fullscreen mode Exit fullscreen mode

randomNum (5 to 10) is just extra full 360° spins layered on top of the target angle, purely for visual tumbling — mod 360, they're a no-op. The actual landing angle is a fixed offset per face value. Because the CSS transform target is derived directly from the number that was already rolled, the visible face and the counted result can never disagree — there's no separate "pick an animation endpoint" step that could drift out of sync with the number in the stats table. That's the opposite of how I originally assumed this worked, and it's the right way to avoid a whole category of dice-roller bugs where the cube visually lands on a 4 while the total quietly counts a 6.

The first data.rollDegreeList reset to all zeros (before the setTimeout) matters too: it snaps the cube back to its resting rotation with no transition class applied yet, so the next roll always animates from a clean 0° instead of continuing from wherever the last roll's multiples-of-360 happened to leave it.

The per-face numbers are reverse-engineered, not derived

What I didn't expect: the offsets in that switch statement aren't a clean formula. Face 1 needs no offset. Face 6 needs x + 180. But face 2 — whose own CSS is a single rotateY(90deg) — needs the cube rotated on both x and z, not just negative y:

case 2: // x90 z90
  return {
    x: 360 * randomNum + 90,
    y: 360 * randomNum,
    z: 360 * randomNum + 90,
  };
Enter fullscreen mode Exit fullscreen mode

The reason is that rotateX, rotateY, and rotateZ on a single element compose in a fixed order and don't commute — rotating 90° on X then 90° on Z doesn't land you back where a single Y rotation would put you, and there's no simple algebraic shortcut from "this face's local transform" to "the cube-level rotation that brings it to the front." The inline comments (//x90 z90, //y270 z90) read like notes left by someone who tested each case by eye and wrote down whatever combination happened to work, rather than someone who derived it from a rotation matrix. Once you notice that, the whole switch statement stops looking arbitrary and starts looking like the honest result of trial and error against actual browser rendering.

Where this actually breaks

The stats table only updates 2300ms after the roll starts (100ms initial delay + a 2000ms CSS transition + a 300ms buffer), which is a magic number tied to transition: transform 2s ease 0s in the stylesheet. Change the transition duration without touching both setTimeout values and the table either reveals itself while the cubes are still visibly spinning, or sits frozen for an extra beat after they've already stopped.

The more interesting bug I found by just reading the template: only the "Roll Dice" button is guarded with :disabled="data.rolling". The quantity input and its range-slider sibling call updateDice() directly on @change/@input, with no check on data.rolling at all. Drag that slider mid-animation and every cube instantly resets to face value 1 with zero rotation — no transition, since updateDice() doesn't touch the rolling class — while the original roll's setTimeout is still ticking down in the background. When it fires, it overwrites the totals table with numbers computed from the dice count you had before you dragged the slider, even though the board on screen now shows a different number of (unrolled-looking) dice. It's a real, reproducible desync between what you see and what the table reports, just triggered by the one input nobody thought to lock during the animation.

There's also no perspective set anywhere in the stylesheet or its ancestors, so the "3D" tumble is really an orthographic approximation — rotations still look convincing at this scale, but it's not true perspective projection, and stacking a lot of dice (the tool warns above 50) is where lower-powered devices and older browsers start struggling to keep 100 independently-transformed cubes animating at once.

I turned the underlying tool into something you can actually roll instead of just reading about: Online Dice Roller Simulator. No installs, works up to 100 dice at a time.


Available in other languages

Top comments (0)