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 fileEvery 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 · <kbd>1</kbd>–<kbd>5</kbd> hold a die ·
<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>