← All gadgets

Dice Roll

Real cubes, tumbling -- up to five, so you can play Yahtzee

Running live from https://fun.whitneys.co, cross-origin and sandboxed.

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/dice-roll"
  width="430"
  height="240"
  title="Dice Roll"
  sandbox="allow-scripts allow-popups-to-escape-sandbox"
  loading="lazy"
  style="border:0"></iframe>

An iframe, not a script tag -- so this gadget cannot touch the page you paste it into, and neither can we. Your settings above are baked into the URL.

The whole thing

Raw file

Every gadget is one file, and this is it -- all 10.0 kB. Take it, change it, ship it. Just keep the credit.

Show source
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<script type="application/json" id="gadget-manifest">
{
  "name": "Dice Roll",
  "tagline": "Real cubes, tumbling -- up to five, so you can play Yahtzee",
  "category": "fun",
  "tags": ["random", "dice", "3d", "yahtzee", "game"],
  "author": "rwhitney",
  "background": "transparent",
  "size": { "w": 430, "h": 240, "resizable": false },
  "config": {
    "sides":  { "type": "range",  "label": "Sides", "min": 2, "max": 20, "step": 1, "default": 6 },
    "count":  { "type": "select", "label": "Dice",  "options": ["1", "2", "3", "4", "5"], "default": "5" },
    "accent": { "type": "color",  "label": "Pips",  "default": "#FD971F" },
    "spin":   { "type": "range",  "label": "Spin (ms)", "min": 200, "max": 1600, "step": 100, "default": 800 }
  },
  "permissions": []
}
</script>
<style>
  html, body {
    margin:0; height:100%; background:transparent; color:#F8F8F2;
    display:grid; place-items:center; gap:16px;
    font:15px/1.4 ui-sans-serif, system-ui, sans-serif;
    overflow:hidden; user-select:none;
  }

  /* Perspective lives on the container so all dice share one vanishing
     point -- give each cube its own and they read as separate little scenes
     rather than objects on one table. */
  /* One shared perspective so five dice look like five objects on one table
     rather than five separate little scenes. wrap is a safety net: the frame
     is sized for five across, but an embedder can always make it narrower. */
  #dice {
    display:flex; flex-wrap:wrap; justify-content:center;
    gap:14px; max-width:100%; padding:0 10px;
    perspective:900px; perspective-origin:50% 40%;
  }

  /* ---- the cube ---------------------------------------------------------
     Six faces, each rotated into place then pushed out by half the cube's
     width. --h must stay exactly half of the width/height or the corners
     do not meet and you get a cube with visible seams. */
  .cube {
    --size: 58px;
    --h: 29px;
    position:relative;
    width:var(--size); height:var(--size);
    transform-style:preserve-3d;
    transition: transform var(--spin, 800ms) cubic-bezier(.22,.72,.25,1);
    will-change: transform;
  }

  .face {
    position:absolute; inset:0;
    box-sizing:border-box;
    display:grid;
    grid-template: repeat(3, 1fr) / repeat(3, 1fr);
    gap:3px; padding:8px;
    background:#F8F8F2;
    border-radius:10px;
    border:1px solid rgb(0 0 0 / .16);
    backface-visibility:hidden;
  }

  .f-front  { transform: rotateY(0deg)    translateZ(var(--h)); }
  .f-back   { transform: rotateY(180deg)  translateZ(var(--h)); }
  .f-right  { transform: rotateY(90deg)   translateZ(var(--h)); }
  .f-left   { transform: rotateY(-90deg)  translateZ(var(--h)); }
  .f-top    { transform: rotateX(90deg)   translateZ(var(--h)); }
  .f-bottom { transform: rotateX(-90deg)  translateZ(var(--h)); }

  /* Shading has to follow which face is pointing at the VIEWER, not which
     slot it occupies on the cube. Baking brightness into .f-right and
     friends looks right until the cube rolls a 3 -- then the right face
     turns to the front still wearing its dark filter, and you get a grey
     die. So: everything is dimmed by default and JS lights whichever face
     the roll landed on. */
  .face { filter: brightness(0.78); }
  .face.lit { filter: none; }

  .pip {
    align-self:center; justify-self:center;
    width:78%; aspect-ratio:1;
    border-radius:50%;
    background: var(--accent, #FD971F);
    box-shadow: inset 0 -1px 1px rgb(0 0 0 / .28);
  }

  /* ---- flat fallback, for dice that are not six-sided ------------------- */
  .tile {
    width:58px; height:58px; border-radius:10px;
    display:grid; place-items:center;
    background:#F8F8F2; color:#272822;
    font-weight:800; font-size:24px;
    font-variant-numeric: tabular-nums;
    border:2px solid var(--accent, #FD971F);
    box-shadow: 0 6px 16px rgb(0 0 0 / .4);
    transition: transform var(--spin, 800ms) cubic-bezier(.22,.72,.25,1);
  }

  #total { font-size:13px; color:var(--accent, #FD971F); font-weight:700;
           letter-spacing:.08em; text-transform:uppercase; min-height:1.2em; }
  button {
    font:inherit; font-weight:700; font-size:13px;
    padding:7px 18px; border-radius:999px; cursor:pointer;
    background:transparent; color:var(--accent, #FD971F);
    border:1px solid currentColor;
  }
  button:hover { background: rgb(255 255 255 / .07); }
  button[disabled] { opacity:.5; cursor:default; }

  @media (prefers-reduced-motion: reduce) {
    .cube, .tile { transition: none; }
  }
</style>
</head>
<body>
  <div id="dice"></div>
  <div id="total" aria-live="polite"></div>
  <button id="roll" type="button">Roll</button>
<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var sides = typeof cfg.sides === 'number' ? cfg.sides : 6;
  var count = parseInt(cfg.count, 10) || 2;
  var spin  = typeof cfg.spin === 'number' ? cfg.spin : 800;
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

  document.documentElement.style.setProperty('--accent', cfg.accent || '#FD971F');
  document.documentElement.style.setProperty('--spin', spin + 'ms');

  // A cube has six faces. Anything else gets the flat tile -- a d20 drawn as
  // a cube would just be wrong.
  var isCube = sides === 6;

  /* Standard pip layouts on a 3x3 grid, numbered row-major 1..9. */
  var PIPS = {
    1: [5],
    2: [1, 9],
    3: [1, 5, 9],
    4: [1, 3, 7, 9],
    5: [1, 3, 5, 7, 9],
    6: [1, 3, 4, 6, 7, 9]
  };

  /* Face layout. Opposite faces sum to 7, as on a real die:
     front 1 / back 6, top 2 / bottom 5, right 3 / left 4. */
  var FACES = [
    { cls: 'f-front',  value: 1 },
    { cls: 'f-back',   value: 6 },
    { cls: 'f-right',  value: 3 },
    { cls: 'f-left',   value: 4 },
    { cls: 'f-top',    value: 2 },
    { cls: 'f-bottom', value: 5 }
  ];

  /* The rotation that brings each value to the front -- the inverse of that
     face's own placement transform. */
  var SHOW = {
    1: { x: 0,   y: 0 },
    6: { x: 0,   y: -180 },
    3: { x: 0,   y: -90 },
    4: { x: 0,   y: 90 },
    2: { x: -90, y: 0 },
    5: { x: 90,  y: 0 }
  };

  /* Landing perfectly square-on would show exactly one face, which draws as
     a flat square -- all that cube geometry and no sign of it. A small
     resting tilt keeps two more faces in view so it reads as a solid object
     sitting on a table. */
  var REST = { x: -16, y: 22 };

  function buildFace(spec) {
    var face = document.createElement('div');
    face.className = 'face ' + spec.cls;
    face.dataset.value = spec.value;
    // Nine grid cells so pips land in fixed positions; only the ones this
    // value needs get filled.
    for (var i = 1; i <= 9; i++) {
      var cell = document.createElement('span');
      if (PIPS[spec.value].indexOf(i) !== -1) cell.className = 'pip';
      face.appendChild(cell);
    }
    return face;
  }

  var box = document.getElementById('dice');
  var totalEl = document.getElementById('total');
  var btn = document.getElementById('roll');
  var dice = [];

  for (var i = 0; i < count; i++) {
    var el;
    if (isCube) {
      el = document.createElement('div');
      el.className = 'cube';
      FACES.forEach(function (spec) { el.appendChild(buildFace(spec)); });
    } else {
      el = document.createElement('div');
      el.className = 'tile';
      el.textContent = '?';
    }
    box.appendChild(el);
    // Rotation accumulators. They only ever grow, so every roll turns
    // forward instead of unwinding back to where it came from.
    dice.push({ el: el, x: 0, y: 0 });
  }

  /* The value that counts comes from the CSP-safe crypto RNG -- fairness is
     the whole point of a die. */
  function fairRoll() {
    var buf = new Uint32Array(1);
    crypto.getRandomValues(buf);
    return (buf[0] % sides) + 1;
  }

  var busy = false;

  function roll() {
    if (busy) return;
    busy = true;
    btn.disabled = true;
    totalEl.textContent = '';

    var finals = dice.map(fairRoll);

    dice.forEach(function (d, i) {
      var v = finals[i];

      if (isCube) {
        var target = SHOW[v];
        // Two whole turns plus a little extra per die, then land exactly on
        // the target orientation. Multiples of 360 keep the landing square.
        // Extra turns cycle 0/1/2 rather than scaling with the index. At five
        // dice a straight i*360 would give the last one three times the
        // angular rate of the first, which reads as one die being flung.
        d.x += 720 + ((i % 3) * 360);
        d.y += 720;
        var rx = d.x + target.x;
        var ry = d.y + target.y;
        d.el.style.transitionDuration = reduce ? '0ms' : (spin + i * 70) + 'ms';

        // The tilt is a SEPARATE outer rotation, not added into rx/ry.
        // Rotations do not commute: rotateX(target + tilt) is not "turn to
        // the face, then tilt it" -- and for the faces that need an X
        // rotation (2 and 5) it lands somewhere else entirely, so those dice
        // came to rest at a visibly different angle from the others.
        // Leftmost function is outermost, so this tilts the already
        // face-aligned cube in view space.
        d.el.style.transform =
          'rotateX(' + REST.x + 'deg) rotateY(' + REST.y + 'deg) ' +
          'rotateX(' + rx + 'deg) rotateY(' + ry + 'deg)';

        // Light the face that is coming to the front, dim the rest.
        [].forEach.call(d.el.querySelectorAll('.face'), function (f) {
          f.classList.toggle('lit', Number(f.dataset.value) === v);
        });
      } else {
        d.x += 720 + ((i % 3) * 180);
        d.el.style.transitionDuration = reduce ? '0ms' : (spin + i * 70) + 'ms';
        d.el.style.transform = 'rotate(' + d.x + 'deg)';
        d.el.textContent = v;
      }
    });

    var wait = reduce ? 0 : spin + (dice.length - 1) * 70;
    setTimeout(function () { settle(finals); }, wait);
  }

  function settle(values) {
    if (values.length > 1) {
      totalEl.textContent = 'total ' + values.reduce(function (a, b) {
        return a + b;
      }, 0);
    }
    busy = false;
    btn.disabled = false;
  }

  btn.addEventListener('click', roll);
  roll();
})();
</script>
</body>
</html>