HTML6 GamesFree Web Games / devlog

No install. Just play.

Free mini games right in your browser. Sign in to save your records to your account and pick up on any device.

All games
All games
All dev log posts

Rolling a 3D die without a library and stopping on the face you want

Nov 1, 2026 · Tech · 한국어

Snakes & Ladders is a race against the computer to reach square 100 by rolling a die. Next to the roll button under the board sits a small die. The roll is the motion you see most in the game, and I reworked this small animation four times.

The first die was a Unicode die glyph like ⚀. Rolling spun it once with a bounce for 0.48 seconds, flipping glyphs quickly before stopping on the result. Two things bothered me. One was that the glyph's size and baseline differ from device to device, so it sagged below the button. I redrew the die as SVG, and later replaced it with six generated images to match the rest of the art.

The other was the motion. A flat picture spinning in the plane of the screen looked like a coin, not a die. The faces changed at an even pace and then stopped abruptly, with no sense of falling and no shadow. I rolled three mockups inside the game and picked the one where the die is tossed up, bounces twice and stops. I added a shadow and made the faces change more and more slowly.

It still felt off. The die was really a single picture seen from above, so the only way to change the number was to swap the picture. The number changed after the die hit the ground, so it looked less like a rolled result and more like someone had switched it. A real die's numbers never change. It tumbles until some face ends up on top.

A cube from six pictures

three.js is already used for the site's 3D games. But Snakes & Ladders is a 2D game drawn with HTML elements, and starting another WebGL canvas for one 52px die next to a button was too much. A cube can be made with CSS alone.

CSS transform can rotate and move elements in 3D space. Rotate each of the six face pictures, push it forward by half the side length (translateZ), and each lands on one side of a box. Give the parent transform-style: preserve-3d so the children share one 3D space instead of being flattened, and put perspective one level above so the browser draws it in perspective. The cube itself is one box element and six faces: seven HTML elements.

// where to attach face n (opposite faces add up to 7)
const PLACE = {
  1: 'rotateY(0)', 2: 'rotateY(90deg)', 3: 'rotateX(90deg)',
  4: 'rotateX(-90deg)', 5: 'rotateY(-90deg)', 6: 'rotateY(180deg)',
};
cube.innerHTML = [1, 2, 3, 4, 5, 6]
  .map((n) => `<i style="transform:${PLACE[n]} translateZ(23px)"></i>`) // half of the 46px side
  .join('');
.die  { width: 52px; height: 52px; perspective: 220px; }
.cube { width: 46px; height: 46px; transform-style: preserve-3d; }
.cube > i { position: absolute; inset: 0; backface-visibility: hidden; }

I set the perspective to 220px, about five times the side length. The smaller it is, the stronger the perspective, and the nearer edges look stretched.

Tilting the cube showed gaps at the edges. The generated face pictures have rounded corners and transparent borders, so the corners stayed empty even with all six faces attached. I added a slightly smaller gray box inside to fill the gaps. It's attached the same way, only pushed out 21px instead of 23px.

Decide the result first, then match the angle

The number is already decided the moment you roll. There's no reason to change it, so the die just has to stop on that face. I keep a table of the cube angle that brings each face to the front. Face 1 stays as is, 6 is 180° around Y, 2 is −90° around Y: the opposite of the angle each face was attached with.

The end angle is that angle plus a whole multiple of 360°. However many times you add 360°, the same face points forward, so picking an end angle far enough from the current one makes the die spin several times and always stop on that face. The number picture never changes. The result face seems to turn out of the spinning die.

// cube angle [rotateX, rotateY] that turns face d to the front
const FRONT = { 1: [0, 0], 2: [0, -90], 3: [-90, 0], 4: [90, 0], 5: [0, 90], 6: [0, 180] };
const turn = ([x, y]) => `rotateX(${x}deg) rotateY(${y}deg)`;
let angle = FRONT[5];

function roll(d) {                // d is already decided
  const [bx, by] = FRONT[d], [cx, cy] = angle;
  // at least one full turn on X and three quarters on Y, ending on face d
  const tx = bx + 360 * Math.ceil((cx + 360 - bx) / 360);
  const ty = by + 360 * Math.ceil((cy + 270 - by) / 360);
  cube.animate([
    { offset: 0, transform: turn([cx, cy]), easing: 'cubic-bezier(.25,.6,.35,1)' },
    { offset: 0.82, transform: turn([tx + 7, ty - 5]) }, // overshoot a little
    { offset: 0.94, transform: turn([tx, ty]) },
    { offset: 1, transform: turn([tx, ty]) },
  ], { duration: 660 });
  angle = FRONT[d];
  cube.style.transform = turn(angle); // same direction, small numbers
}

The rotation uses a curve that starts fast and slows down, and at the end it overshoots the result face by a few degrees and comes back. That small wobble is what makes it look like something with weight coming to rest.

One thing to watch out for. When the first and last keyframes both use the same functions in the same order, like rotateX() rotateY(), the browser interpolates the angle numbers directly. Going from 0° to 720° really spins twice. If the kinds or order of functions differ, the browser converts both to matrices and interpolates those. A matrix keeps no record of full turns, so it takes the shortest path.

When the animation ends, style.transform is set back to the original angle with the multiples of 360° removed. It points the same way, so nothing changes on screen, and the next roll starts from that small angle. Without this, the angle numbers would keep growing with every roll.

Which face rotateX(a) rotateY(b) actually brings forward is easy to get wrong. Instead of checking the table in my head, I checked it in the browser. I multiplied the cube's transform and each face's transform as DOMMatrix objects, moved the forward direction (0, 0, 1) through them, and found the face whose z came out as 1. All six faces matched the table, and on the real board twelve rolls showed the rolled number on the front face.

Rotation, bounce and shadow move separately

Putting every motion on the cube alone makes the keyframes messy. So I split it across three elements. The cube only rotates. The wrapper around it is tossed up 30px, lands at 330ms squashing slightly, bounces 8px once more and settles. At 590ms, when the result is out, it pops to 1.14× and back. A blurred oval shadow on the floor shrinks and fades while the die is up, and returns to normal when it lands. The shadow alone lets the eye read the height.

All three move with Element.animate() (the Web Animations API), 660ms in total. By the landing at 330ms the die has already turned most of the way and the result face is almost visible. The game piece starts walking at 580ms, so the next action begins just before the die fully stops. Only transform and opacity change, so the browser doesn't redo layout.

There are limits. There's no lighting, so side faces are as bright as the front. It isn't physics, so it rolls the same way every time. Dice that roll across the floor or bump into each other would need WebGL and a physics engine. For a small die next to a button, this was enough. Having the number turn into view instead of switching made the awkwardness almost disappear.

References

Games from this post

Snakes & LaddersRoll the dice and reach square 100 before the computer. Climb ladders, slide down snakes! Play

More posts

Add to Home Screen

  1. Tap the Share button at the bottom of the screen (top on iPad). It looks like a square with an arrow pointing up.
  2. Scroll down and tap "Add to Home Screen".
  3. Tap "Add" at the top right. The HTML6 Games icon appears on your home screen.

This works in Safari. Opened from the home screen, it runs without the address bar, like an app, and even offline.