← All gadgets

Quince

Thirteen rounds, three rolls each, up to four players

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

Put it on your site

<iframe
  src="https://fun.whitneys.co/g/quince"
  width="460"
  height="720"
  title="Quince"
  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.

Optional: let it resize itself to fit
<script>
addEventListener('message', function (e) {
  var d = e.data;
  if (!d || d.type !== 'fwf:height') return;
  if (typeof d.height !== 'number' || !isFinite(d.height)) return;
  document.querySelectorAll('iframe').forEach(function (f) {
    // Identity by source window, not by origin -- sandboxed frames report
    // their origin as "null". Do not "fix" this into an origin check.
    if (f.contentWindow !== e.source) return;
    f.style.height = Math.max(40, Math.min(2000, Math.ceil(d.height))) + 'px';
  });
});
</script>

The whole thing

Raw file

Every gadget is one file, and this is it -- all 25.1 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": "Quince",
  "tagline": "Thirteen rounds, three rolls each, up to four players",
  "category": "fun",
  "tags": ["dice", "game", "scorecard", "multiplayer"],
  "author": "rwhitney",
  "background": "transparent",
  "size": { "w": 460, "h": 720, "resizable": true },
  "config": {
    "players": { "type": "select", "label": "Players", "options": ["1", "2", "3", "4"], "default": "2" },
    "names":   { "type": "text",   "label": "Names (comma separated)", "max": 60, "default": "" },
    "accent":  { "type": "color",  "label": "Pips",  "default": "#FD971F" },
    "spin":    { "type": "range",  "label": "Spin (ms)", "min": 200, "max": 1400, "step": 100, "default": 700 },
    "hints":   { "type": "bool",   "label": "Show what each box would score", "default": true }
  },
  "permissions": []
}
</script>
<style>
  :root {
    --ink:#F8F8F2; --dim:#A59F85; --line:rgb(248 248 242 / .14);
    --panel:rgb(248 248 242 / .04);
  }
  html, body {
    margin:0; background:transparent; color:var(--ink);
    font:14px/1.45 ui-sans-serif, system-ui, sans-serif;
    user-select:none;
  }
  body { padding:14px 14px 18px; }

  /* This gadget is embedded at whatever width the host gives it -- a ~360px
     masonry column in the gallery, ~650px on its own listing page. Sizes
     below scale with the viewport so it stays comfortable at both instead of
     assuming its declared width. */

  /* ---- dice ------------------------------------------------------------ */
  #tray {
    display:flex; flex-wrap:wrap; justify-content:center;
    gap:clamp(2px, 1vw, 8px);
    perspective:900px; perspective-origin:50% 40%;
    min-height:70px; align-items:center;
  }
  .slot { display:grid; place-items:center; gap:4px; }
  .cube {
    /* Five dice plus gaps and padding have to fit the narrowest sensible
       host. clamp keeps them chunky when there is room and shrinks them
       rather than wrapping to a second row when there is not. */
    --size:clamp(38px, 11vw, 52px);
    --h:calc(var(--size) / 2);
    position:relative; width:var(--size); height:var(--size);
    transform-style:preserve-3d; cursor:pointer;
    transition: transform var(--spin,700ms) cubic-bezier(.22,.72,.25,1);
  }
  .cube.locked { cursor:default; }
  .face {
    position:absolute; inset:0; box-sizing:border-box;
    display:grid; grid-template:repeat(3,1fr)/repeat(3,1fr);
    gap:2px; padding:7px;
    background:#F8F8F2; border-radius:9px;
    border:1px solid rgb(0 0 0 / .16);
    backface-visibility:hidden;
    filter:brightness(.78);
  }
  .face.lit { filter:none; }
  .f-front{transform:rotateY(0) 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))}
  .pip { align-self:center; justify-self:center; width:76%; aspect-ratio:1;
         border-radius:50%; background:var(--accent,#FD971F);
         box-shadow: inset 0 -1px 1px rgb(0 0 0 / .28); }

  .hold {
    font-size:9px; letter-spacing:.14em; text-transform:uppercase;
    color:transparent; font-weight:700;
  }
  .slot.held .hold { color:var(--accent,#FD971F); }

  /* The held marker goes on the SLOT, never on the cube.
     `filter` is a grouping property: applying one to an element forces it to
     flatten, which destroys transform-style:preserve-3d on its children. A
     drop-shadow on .cube collapsed the whole die into a flat sliver. Any of
     filter, opacity, mask, or overflow on a preserve-3d element does this. */
  /* Padding has to clear the cube's RENDERED bounds, not its layout box. A
     tilted 3D cube projects wider and taller than its 52px box, so with
     tight padding both the held border and the focus ring were drawn
     underneath the die and effectively invisible. */
  .slot { padding:7px; border-radius:13px; border:1px solid transparent; }

  /* Focus ring goes on the SLOT, not the cube. An outline on a 3D-rotated
     element is drawn in that element's rotated plane, so it skews with the
     die and stops reading as a focus indicator. Cyan rather than the accent
     so "focused" is never confused with "held". */
  /* :focus-visible rather than :focus-within, so clicking a die does not
     leave a ring behind -- the ring is for people navigating by keyboard. */
  .slot:has(.cube:focus-visible) {
    outline:2px solid #66D9EF; outline-offset:2px;
  }
  .cube:focus { outline:none; }
  .cube:focus-visible { outline:none; }

  .sr {
    position:absolute; width:1px; height:1px; overflow:hidden;
    clip-path:inset(50%); white-space:nowrap;
  }
  #keys { font-size:10.5px; color:var(--dim); text-align:center;
          margin:8px 0 0; }
  #keys kbd {
    font:inherit; font-size:10px; padding:1px 5px; border-radius:4px;
    border:1px solid var(--line); background:var(--panel); color:var(--ink);
  }
  .slot.held {
    background: color-mix(in srgb, var(--accent,#FD971F) 14%, transparent);
    border-color: var(--accent,#FD971F);
  }

  /* ---- controls -------------------------------------------------------- */
  #bar { display:flex; align-items:center; justify-content:center; gap:12px;
         margin:12px 0 4px; }
  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:not([disabled]) { background:rgb(255 255 255 / .07); }
  button[disabled] { opacity:.4; cursor:default; }
  #status { font-size:12px; color:var(--dim); text-align:center;
            min-height:2.6em; margin:2px 0 10px; }

  /* ---- scorecard ------------------------------------------------------- */
  /* Two score columns when there is room, one when there is not -- below
     ~330px the labels start truncating, which makes the card unreadable. */
  #card { display:grid; grid-template-columns:1fr 1fr; gap:0 12px; }
  @media (max-width: 330px) { #card { grid-template-columns:1fr; } }
  h2 { font-size:10px; letter-spacing:.16em; text-transform:uppercase;
       color:var(--dim); margin:0 0 4px; font-weight:700; }
  .box {
    display:flex; justify-content:space-between; align-items:center; gap:8px;
    width:100%; padding:4px 7px; margin:0 0 2px;
    border:1px solid transparent; border-radius:5px;
    background:var(--panel); color:var(--ink);
    font:inherit; font-size:12.5px; text-align:left;
    cursor:default;
  }
  .box .val { font-variant-numeric:tabular-nums; font-weight:700; }
  .box.open { cursor:pointer; border-color:var(--line); }
  .box.open:hover { background:rgb(255 255 255 / .09);
                    border-color:var(--accent,#FD971F); }
  .box.open .val { color:var(--accent,#FD971F); }
  /* A zero is a real, sometimes correct choice -- make it legible rather
     than hiding it, so sacrificing a box is a visible decision. */
  .box.open.zero .val { color:var(--dim); }
  .box.taken { opacity:.72; }
  .box.taken .val { color:var(--ink); }
  .rule { border-top:1px solid var(--line); margin:6px 0; }
  .sum { display:flex; justify-content:space-between; font-size:11.5px;
         color:var(--dim); padding:2px 7px; }
  .sum b { color:var(--ink); font-variant-numeric:tabular-nums; }
  #grand { margin-top:10px; text-align:center; font-size:13px;
           letter-spacing:.1em; text-transform:uppercase; color:var(--dim); }
  #grand b { color:var(--accent,#FD971F); font-size:22px; display:block;
             letter-spacing:0; font-variant-numeric:tabular-nums; }

  /* ---- players --------------------------------------------------------- */
  #players { display:flex; flex-wrap:wrap; gap:6px; justify-content:center;
             margin:0 0 12px; }
  .pchip {
    display:flex; align-items:baseline; gap:7px;
    padding:4px 11px; border-radius:999px;
    border:1px solid var(--line); background:var(--panel);
    color:var(--dim); font:inherit; font-size:12px; font-weight:600;
    cursor:pointer;
  }
  .pchip b { color:var(--ink); font-variant-numeric:tabular-nums; }
  .pchip:hover { border-color:var(--dim); }
  /* Two distinct states, because they answer different questions: whose turn
     is it (active), and whose card am I looking at (viewing). They are
     usually the same chip, and it matters when they are not. */
  .pchip.active { color:var(--ink); border-color:var(--accent,#FD971F); }
  .pchip.active::before {
    content:''; width:6px; height:6px; border-radius:50%;
    background:var(--accent,#FD971F); align-self:center;
  }
  .pchip.viewing { background:rgb(255 255 255 / .10); }
  .pchip.winner b { color:var(--accent,#FD971F); }

  #peek { text-align:center; font-size:11px; color:var(--accent,#FD971F);
          margin:0 0 6px; min-height:1.3em; }

  @media (prefers-reduced-motion: reduce) { .cube { transition:none; } }
</style>
</head>
<body>
  <div id="players"></div>
  <div id="tray"></div>
  <div id="bar">
    <button id="roll" type="button">Roll</button>
    <button id="again" type="button" hidden>Play again</button>
  </div>
  <div id="status" aria-live="polite"></div>
  <div id="peek"></div>
  <p id="keys">
    <kbd>R</kbd> roll &middot; <kbd>1</kbd>–<kbd>5</kbd> hold a die &middot;
    <kbd>Tab</kbd> to the scorecard
  </p>
  <div id="announce" class="sr" role="status" aria-live="polite"></div>
  <div id="card">
    <section><h2>Upper</h2><div id="upper"></div>
      <div class="sum"><span>Bonus at 63</span><b id="bonus">0</b></div>
      <div class="rule"></div>
      <div class="sum"><span>Upper total</span><b id="uptotal">0</b></div>
    </section>
    <section><h2>Lower</h2><div id="lower"></div>
      <div class="rule"></div>
      <div class="sum"><span>Lower total</span><b id="lowtotal">0</b></div>
    </section>
  </div>
  <div id="grand">Total <b id="total">0</b></div>

<script>
(function () {
  var cfg = window.gadget ? window.gadget.config : {};
  var spin = typeof cfg.spin === 'number' ? cfg.spin : 700;
  var hints = cfg.hints !== false;
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
  document.documentElement.style.setProperty('--accent', cfg.accent || '#FD971F');
  document.documentElement.style.setProperty('--spin', spin + 'ms');

  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] };
  var FACES = [
    { cls:'f-front', v:1 }, { cls:'f-back', v:6 }, { cls:'f-right', v:3 },
    { cls:'f-left', v:4 }, { cls:'f-top', v:2 }, { cls:'f-bottom', v:5 }
  ];
  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} };
  var REST = { x:-16, y:22 };

  /* ---- scoring --------------------------------------------------------
     Counts is a 7-slot array indexed by face value, so counts[3] is how many
     threes are showing. Every rule below reads off that rather than
     re-scanning the dice. */
  function counts(vals) {
    var c = [0,0,0,0,0,0,0];
    vals.forEach(function (v) { c[v]++; });
    return c;
  }
  function sum(vals) { return vals.reduce(function (a,b) { return a+b; }, 0); }
  function ofAKind(vals, n) {
    var c = counts(vals);
    for (var v = 1; v <= 6; v++) if (c[v] >= n) return sum(vals);
    return 0;
  }
  function runLength(vals) {
    var c = counts(vals), best = 0, run = 0;
    for (var v = 1; v <= 6; v++) {
      if (c[v]) { run++; if (run > best) best = run; }
      else run = 0;
    }
    return best;
  }

  var CATS = [
    { key:'ones',   label:'Ones',   sec:'up', fn:function(d){ return counts(d)[1]*1; } },
    { key:'twos',   label:'Twos',   sec:'up', fn:function(d){ return counts(d)[2]*2; } },
    { key:'threes', label:'Threes', sec:'up', fn:function(d){ return counts(d)[3]*3; } },
    { key:'fours',  label:'Fours',  sec:'up', fn:function(d){ return counts(d)[4]*4; } },
    { key:'fives',  label:'Fives',  sec:'up', fn:function(d){ return counts(d)[5]*5; } },
    { key:'sixes',  label:'Sixes',  sec:'up', fn:function(d){ return counts(d)[6]*6; } },

    { key:'three',  label:'Three of a kind', sec:'low', fn:function(d){ return ofAKind(d,3); } },
    { key:'four',   label:'Four of a kind',  sec:'low', fn:function(d){ return ofAKind(d,4); } },
    { key:'house',  label:'Full house',      sec:'low', fn:function(d){
        var c = counts(d).slice(1).filter(Boolean).sort(function(a,b){return b-a;});
        // Five of a kind counts as a full house here -- the common house
        // rule, and it avoids a five-of-a-kind scoring zero in a box it
        // plainly satisfies.
        return (c[0] === 5 || (c[0] === 3 && c[1] === 2)) ? 25 : 0; } },
    { key:'small',  label:'Small straight',  sec:'low', fn:function(d){ return runLength(d) >= 4 ? 30 : 0; } },
    { key:'large',  label:'Large straight',  sec:'low', fn:function(d){ return runLength(d) >= 5 ? 40 : 0; } },
    { key:'five',   label:'Five of a kind',  sec:'low', fn:function(d){ return ofAKind(d,5) ? 50 : 0; } },
    { key:'chance', label:'Chance',          sec:'low', fn:function(d){ return sum(d); } }
  ];

  /* ---- state ----------------------------------------------------------
     In memory only. This gadget declares no permissions, so it has no
     storage -- a reload starts a new game, by design rather than oversight.

     `active` is whose turn it is. `viewing` is whose card is on screen.
     They are usually equal; keeping them separate is what lets you check an
     opponent's card mid-turn without being able to write to it. */
  var playerCount = parseInt(cfg.players, 10) || 1;
  if (playerCount < 1 || playerCount > 4) playerCount = 1;

  function playerNames() {
    // Config text is attacker-controlled and already length-capped by the
    // server; still take only as many names as there are players and fall
    // back per-slot rather than trusting the string to be well formed.
    var given = String(cfg.names || '').split(',')
      .map(function (s) { return s.trim().slice(0, 14); })
      .filter(function (s) { return s.length; });
    var out = [];
    for (var i = 0; i < playerCount; i++) {
      out.push(given[i] || (playerCount === 1 ? 'You' : 'Player ' + (i + 1)));
    }
    return out;
  }

  var players = playerNames().map(function (name) {
    return { name: name, scores: {} };
  });

  var dice = [], rollsLeft = 3, rolledThisTurn = false, busy = false;
  var active = 0, viewing = 0, round = 0;

  var tray = document.getElementById('tray');
  var rollBtn = document.getElementById('roll');
  var againBtn = document.getElementById('again');
  var statusEl = document.getElementById('status');

  function fair() {
    var b = new Uint32Array(1);
    crypto.getRandomValues(b);
    return (b[0] % 6) + 1;
  }

  function buildCube() {
    var slot = document.createElement('div');
    slot.className = 'slot';
    var cube = document.createElement('div');
    cube.className = 'cube';
    // A div with a click handler is invisible to a keyboard and to assistive
    // tech. Holding a die is half the game, so it has to be a real control:
    // focusable, announced, and toggleable without a pointer.
    cube.tabIndex = 0;
    cube.setAttribute('role', 'button');
    FACES.forEach(function (f) {
      var face = document.createElement('div');
      face.className = 'face ' + f.cls;
      face.dataset.value = f.v;
      for (var i = 1; i <= 9; i++) {
        var cell = document.createElement('span');
        if (PIPS[f.v].indexOf(i) !== -1) cell.className = 'pip';
        face.appendChild(cell);
      }
      cube.appendChild(face);
    });
    var tag = document.createElement('div');
    tag.className = 'hold';
    tag.textContent = 'held';
    slot.appendChild(cube);
    slot.appendChild(tag);
    tray.appendChild(slot);
    return { slot: slot, cube: cube, value: 1, held: false, x: 0, y: 0 };
  }

  for (var i = 0; i < 5; i++) dice.push(buildCube());

  function labelDie(d, i) {
    d.cube.setAttribute('aria-label',
      'Die ' + (i + 1) + ', showing ' + d.value + (d.held ? ', held' : ''));
    d.cube.setAttribute('aria-pressed', d.held ? 'true' : 'false');
    d.cube.setAttribute('aria-disabled', canHold() ? 'false' : 'true');
  }

  function canHold() {
    // Holding only means anything once there are dice worth holding, never
    // mid-roll, never on the last roll, and never on someone else's card.
    // That last condition was missing before -- you could toggle holds while
    // peeking at an opponent.
    return !busy && rolledThisTurn && rollsLeft > 0 &&
           viewing === active && !gameOver();
  }

  function toggleHold(d, i) {
    if (!canHold()) return;
    d.held = !d.held;
    d.slot.classList.toggle('held', d.held);
    labelDie(d, i);
    announce('Die ' + (i + 1) + (d.held ? ' held' : ' released'));
  }

  function announce(msg) {
    document.getElementById('announce').textContent = msg;
  }

  dice.forEach(function (d, i) {
    d.cube.addEventListener('click', function () { toggleHold(d, i); });
    d.cube.addEventListener('keydown', function (e) {
      if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
      e.preventDefault();          // Space would scroll the frame otherwise
      toggleHold(d, i);
    });
    show(d, 1, 0, true);
    labelDie(d, i);
  });

  /* Shortcuts. The gadget is a cross-origin iframe, so these only fire when
     it has focus -- they cannot shadow the host page's own keys. */
  document.addEventListener('keydown', function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    var tag = e.target.tagName;
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;

    if (e.key >= '1' && e.key <= '5') {
      var idx = Number(e.key) - 1;
      if (dice[idx]) { e.preventDefault(); toggleHold(dice[idx], idx); }
      return;
    }
    if (e.key === 'r' || e.key === 'R') {
      if (!rollBtn.disabled && !rollBtn.hidden) { e.preventDefault(); roll(); }
    }
  });

  function show(d, v, i, instant) {
    d.value = v;
    d.x += instant ? 0 : 720 + ((i % 3) * 360);
    d.y += instant ? 0 : 720;
    var t = SHOW[v];
    d.cube.style.transitionDuration =
      (instant || reduce) ? '0ms' : (spin + i * 70) + 'ms';
    // Tilt is a separate OUTER rotation -- folding it into the face angles
    // does not commute, and dice landing on 2 or 5 end up askew.
    d.cube.style.transform =
      'rotateX(' + REST.x + 'deg) rotateY(' + REST.y + 'deg) ' +
      'rotateX(' + (d.x + t.x) + 'deg) rotateY(' + (d.y + t.y) + 'deg)';
    [].forEach.call(d.cube.querySelectorAll('.face'), function (f) {
      f.classList.toggle('lit', Number(f.dataset.value) === v);
    });
  }

  function values() { return dice.map(function (d) { return d.value; }); }

  function roll() {
    if (busy || rollsLeft === 0) return;
    busy = true;
    rollBtn.disabled = true;
    rollsLeft--;
    rolledThisTurn = true;

    var moving = 0;
    dice.forEach(function (d, i) {
      if (d.held) return;
      show(d, fair(), moving++, false);
    });

    var wait = reduce ? 0 : spin + Math.max(0, moving - 1) * 70;
    setTimeout(function () {
      busy = false;
      dice.forEach(labelDie);
      announce('Rolled ' + values().join(', ') +
        (rollsLeft ? '. ' + rollsLeft + ' rolls left.' : '. No rolls left.'));
      render();
    }, wait);
    render();
  }

  function pick(cat) {
    // Only the active player may write, and only to their own card. Viewing
    // someone else's card must never be able to score into it.
    if (busy || !rolledThisTurn || viewing !== active) return;
    var card = players[active].scores;
    if (cat.key in card) return;

    card[cat.key] = cat.fn(values());
    announce(players[active].name + ' scored ' + card[cat.key] +
      ' in ' + cat.label + '.');

    active = (active + 1) % players.length;
    if (active === 0) round++;      // a round is one turn for everybody
    viewing = active;
    rollsLeft = 3;
    rolledThisTurn = false;
    dice.forEach(function (d, i) {
      d.held = false;
      d.slot.classList.remove('held');
      labelDie(d, i);
    });
    render();
  }

  function upperSubtotal(scores) {
    return CATS.filter(function (c) { return c.sec === 'up'; })
      .reduce(function (t, c) { return t + (scores[c.key] || 0); }, 0);
  }

  function totalFor(p) {
    var sub = upperSubtotal(p.scores);
    var low = CATS.filter(function (c) { return c.sec === 'low'; })
      .reduce(function (t, c) { return t + (p.scores[c.key] || 0); }, 0);
    return sub + (sub >= 63 ? 35 : 0) + low;
  }

  function gameOver() { return round >= CATS.length; }

  function renderPlayers() {
    var strip = document.getElementById('players');
    strip.innerHTML = '';
    if (players.length === 1) return;   // no strip worth drawing for solo

    var best = Math.max.apply(null, players.map(totalFor));
    var done = gameOver();

    players.forEach(function (p, i) {
      var chip = document.createElement('button');
      chip.type = 'button';
      chip.className = 'pchip' +
        (i === active && !done ? ' active' : '') +
        (i === viewing ? ' viewing' : '') +
        (done && totalFor(p) === best ? ' winner' : '');
      var n = document.createElement('span');
      n.textContent = p.name;
      var t = document.createElement('b');
      t.textContent = totalFor(p);
      chip.appendChild(n);
      chip.appendChild(t);
      chip.addEventListener('click', function () { viewing = i; render(); });
      strip.appendChild(chip);
    });
  }

  function render() {
    var up = document.getElementById('upper');
    var low = document.getElementById('lower');
    up.innerHTML = '';
    low.innerHTML = '';

    var vals = values();
    var done = gameOver();
    var scores = players[viewing].scores;
    var mine = viewing === active && !done;

    renderPlayers();
    // aria-disabled on the dice tracks game state, not just hold state --
    // whose turn it is, whether a roll is in flight, whose card is showing.
    dice.forEach(labelDie);

    CATS.forEach(function (cat) {
      var taken = cat.key in scores;
      var el = document.createElement(taken ? 'div' : 'button');
      if (!taken) el.type = 'button';
      el.className = 'box';

      var name = document.createElement('span');
      name.textContent = cat.label;
      var val = document.createElement('span');
      val.className = 'val';

      if (taken) {
        el.classList.add('taken');
        val.textContent = scores[cat.key];
      } else if (mine && rolledThisTurn && !busy) {
        el.classList.add('open');
        el.addEventListener('click', function () { pick(cat); });
        if (hints) {
          var would = cat.fn(vals);
          if (would === 0) el.classList.add('zero');
          val.textContent = would;
        } else {
          val.textContent = '–';
        }
      } else {
        // Someone else's card, or nothing rolled yet: readable, not writable.
        val.textContent = '–';
      }

      el.appendChild(name);
      el.appendChild(val);
      (cat.sec === 'up' ? up : low).appendChild(el);
    });

    var sub = upperSubtotal(scores);
    var bonus = sub >= 63 ? 35 : 0;
    document.getElementById('bonus').textContent =
      bonus ? '+35' : (sub + '/63');
    document.getElementById('uptotal').textContent = sub + bonus;

    var lowTotal = CATS.filter(function (c) { return c.sec === 'low'; })
      .reduce(function (t, c) { return t + (scores[c.key] || 0); }, 0);
    document.getElementById('lowtotal').textContent = lowTotal;
    document.getElementById('total').textContent = sub + bonus + lowTotal;

    rollBtn.disabled = busy || rollsLeft === 0 || done || !mine;
    rollBtn.textContent = rollsLeft === 3 ? 'Roll' : 'Roll (' + rollsLeft + ' left)';
    rollBtn.hidden = done;
    againBtn.hidden = !done;

    var solo = players.length === 1;
    var who = players[active].name;

    // Peek line only appears when you are looking at a card you cannot play.
    document.getElementById('peek').textContent = (!done && !mine)
      ? 'Viewing ' + players[viewing].name + ' -- tap ' + who + ' to play'
      : '';

    if (done) {
      var best = Math.max.apply(null, players.map(totalFor));
      var winners = players.filter(function (p) { return totalFor(p) === best; });
      statusEl.textContent = solo
        ? 'Card full. Final score ' + best + '.'
        : (winners.length > 1
            ? 'Tied at ' + best + ': ' +
              winners.map(function (p) { return p.name; }).join(' and ') + '.'
            : winners[0].name + ' wins with ' + best + '.');
    } else if (busy) {
      statusEl.textContent = '';
    } else if (!mine) {
      statusEl.textContent = '';
    } else if (!rolledThisTurn) {
      statusEl.textContent = 'Round ' + (round + 1) + ' of 13' +
        (solo ? '' : ' -- ' + who + "'s turn") + '. Roll to start.';
    } else if (rollsLeft > 0) {
      statusEl.textContent = 'Tap dice to hold them, roll again, ' +
        'or take a box below.';
    } else {
      statusEl.textContent = 'No rolls left -- take a box below. ' +
        'A zero is allowed if nothing fits.';
    }
  }

  rollBtn.addEventListener('click', roll);
  againBtn.addEventListener('click', function () {
    players.forEach(function (p) { p.scores = {}; });
    active = 0; viewing = 0; round = 0;
    rollsLeft = 3; rolledThisTurn = false;
    dice.forEach(function (d, i) {
      d.held = false;
      d.slot.classList.remove('held');
      labelDie(d, i);
    });
    render();
  });

  render();
})();
</script>
</body>
</html>