0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Roll one to six dice as many times as you like and watch the histogram of sums pile up underneath the exact theoretical curve drawn over it. Any single roll is unpredictable, but the shape of a few thousand of them is not — the worst gap between what actually happened and what the math says is printed live in percentage points, and it collapses as the rolls accumulate. Try hitting ×1000 three or four times with 2 dice and watching the bars snap onto the line, then flip to 1 die (dead flat) and to 6 (a clean bell). The expected-vs-observed table shows why 7 wins with two dice: 6 of the 36 combinations add up to it, and only one adds up to 2.
Source — the code you see is the code that just ran 1186 lines 44.5 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<title>Dice Probability Lab</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
line-height: 1.5;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
font-size: 15px;
}
.wrap { max-width: 1120px; margin: 0 auto; padding: 18px 14px 30px; }
h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -0.01em; }
.mono { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.sr {
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
/* ---------- header ---------- */
.head {
display: flex; flex-wrap: wrap; gap: 12px 16px;
align-items: flex-start; justify-content: space-between;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.head h1 { font-size: 1.32rem; }
.lede { margin: 5px 0 0; color: var(--muted); max-width: 60ch; font-size: 0.95rem; }
.status {
display: inline-flex; align-items: center; gap: 8px;
font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0.02em;
padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px;
background: var(--surface); color: var(--muted); white-space: nowrap; flex-shrink: 0;
}
.status .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.status[data-state="run"] .dot { background: var(--accent); animation: pulse 1.1s ease-in-out infinite; }
.status[data-state="run"] { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.status[data-state="ok"] .dot { background: var(--run); }
.status[data-state="ok"] { color: var(--run); border-color: var(--run); background: var(--run-soft); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ---------- layout ---------- */
.grid { display: grid; gap: 14px; grid-template-columns: 286px minmax(0, 1fr); align-items: start; }
.col { display: grid; gap: 14px; min-width: 0; }
/* the readouts column is tall; keep the controls reachable while reading it */
@media (min-width: 881px) { .controls { position: sticky; top: 14px; } }
@media (max-width: 880px) {
.grid { grid-template-columns: minmax(0, 1fr); }
}
.panel {
background: var(--surface); border: 1px solid var(--line);
border-radius: var(--radius); padding: 14px; min-width: 0;
}
.panel > h2 {
font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase;
letter-spacing: 0.09em; color: var(--muted); font-weight: 600; margin-bottom: 11px;
}
/* ---------- buttons ---------- */
button {
font: inherit; color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 7px;
padding: 7px 11px; min-height: 36px; cursor: pointer;
font-family: var(--font-mono); font-size: 0.75rem;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
button:hover { border-color: var(--accent); color: var(--accent); }
button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
.picker { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 6px; }
.picker button { min-width: 0; padding: 7px 2px; font-size: 0.82rem; font-weight: 600; }
.picker-legend, .grp-legend {
font-family: var(--font-mono); font-size: 0.63rem; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--muted); display: block; margin-bottom: 6px;
}
.rollrow { display: flex; gap: 6px; margin-bottom: 8px; }
.rollrow button { flex: 1 1 0; }
.rollrow button.primary {
flex: 1.35 1 0; background: var(--accent-soft); border-color: var(--accent);
color: var(--accent); font-weight: 600;
}
.rollrow button.primary:hover { background: var(--accent); color: var(--surface); }
.utilrow { display: flex; gap: 6px; }
.utilrow button { flex: 1 1 0; }
.hint { font-family: var(--font-mono); font-size: 0.66rem; color: var(--muted); margin: 10px 0 0; line-height: 1.6; }
/* ---------- dice tray ---------- */
.tray-wrap { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.tray { display: flex; gap: 8px; flex-wrap: wrap; min-width: 0; }
.die {
/* --dw drives every internal measure: percentage padding would resolve
against the tray, not the die, and collapse the pips. */
--dw: clamp(36px, 11vw, 52px);
width: var(--dw); height: var(--dw);
border-radius: calc(var(--dw) * 0.22); background: var(--bg); border: 1px solid var(--line);
display: grid; grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr);
padding: calc(var(--dw) * 0.13); gap: calc(var(--dw) * 0.06); flex: none;
}
.die i { border-radius: 50%; background: transparent; display: block; }
.die i.on { background: var(--ink); }
.die.roll { animation: tumble 0.42s cubic-bezier(.36,.07,.19,.97); }
@keyframes tumble {
0% { transform: translateY(0) rotate(0deg); }
25% { transform: translateY(-13px) rotate(-19deg); }
55% { transform: translateY(-5px) rotate(13deg); }
80% { transform: translateY(-2px) rotate(-6deg); }
100% { transform: translateY(0) rotate(0deg); }
}
.sum-badge { display: flex; align-items: baseline; gap: 7px; margin-left: auto; }
.sum-badge .eq { font-family: var(--font-mono); font-size: 1.1rem; color: var(--muted); }
.sum-badge .val {
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 2.3rem; font-weight: 600; line-height: 1; letter-spacing: -0.03em; color: var(--accent);
}
.sum-badge .lab { font-family: var(--font-mono); font-size: 0.62rem; color: var(--muted); letter-spacing: 0.07em; }
/* ---------- stats ---------- */
.stats {
display: grid; grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 1px; background: var(--line); border: 1px solid var(--line);
border-radius: var(--radius); overflow: hidden; margin: 0;
}
.stat { background: var(--surface); padding: 10px 12px; min-width: 0; }
.stat dt {
font-family: var(--font-mono); font-size: 0.62rem; text-transform: uppercase;
letter-spacing: 0.07em; color: var(--muted); margin-bottom: 3px;
}
.stat dd {
margin: 0; font-family: var(--font-mono); font-variant-numeric: tabular-nums;
font-size: 1.16rem; font-weight: 600; letter-spacing: -0.02em;
}
.stat dd small {
font-size: 0.62rem; font-weight: 400; color: var(--muted);
display: block; letter-spacing: 0.03em; margin-top: 2px;
}
.stat dd.good { color: var(--run); }
/* ---------- charts ---------- */
.chart-head { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: baseline; justify-content: space-between; margin-bottom: 10px; }
.chart-head h2 { font-family: var(--font-mono); font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.09em; color: var(--muted); font-weight: 600; }
.legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-family: var(--font-mono); font-size: 0.67rem; color: var(--muted); }
.legend span { display: inline-flex; align-items: center; gap: 6px; }
.swatch { width: 13px; height: 10px; border-radius: 2px; display: inline-block; border: 1px solid transparent; }
.sw-obs { background: var(--accent-soft); border-color: var(--accent); }
.sw-th { height: 0; border: 0; border-top: 2px solid var(--ink); width: 15px; border-radius: 0; }
canvas { display: block; width: 100%; border-radius: 6px; touch-action: pan-y; }
canvas:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.readout {
margin-top: 9px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 7px;
background: var(--bg); font-family: var(--font-mono); font-size: 0.72rem;
font-variant-numeric: tabular-nums; display: flex; flex-wrap: wrap;
gap: 3px 14px; min-height: 35px; align-items: center;
}
.readout b { font-weight: 600; color: var(--accent); }
.readout .k { color: var(--muted); }
.insight { margin: 10px 0 0; font-size: 0.87rem; color: var(--muted); }
.insight b { color: var(--ink); font-weight: 600; }
/* ---------- table ---------- */
.tbl-scroll { max-height: 300px; overflow: auto; border: 1px solid var(--line); border-radius: 7px; }
table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 0.73rem; }
thead th {
position: sticky; top: 0; background: var(--bg); text-align: right;
font-weight: 600; font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.07em;
color: var(--muted); padding: 7px 9px; border-bottom: 1px solid var(--line); white-space: nowrap;
}
thead th:first-child { text-align: left; }
tbody td { padding: 5px 9px; text-align: right; border-bottom: 1px solid var(--line); white-space: nowrap; }
tbody td:first-child { text-align: left; font-weight: 600; }
tbody tr:last-child td { border-bottom: 0; }
tbody tr { cursor: pointer; }
tbody tr:hover td { background: var(--bg); }
tbody tr.sel td { background: var(--accent-soft); }
tbody tr.sel td:first-child { color: var(--accent); }
tbody td.dim { color: var(--muted); }
.gapcell { display: flex; align-items: center; justify-content: flex-end; gap: 7px; }
.gapbar { height: 6px; border-radius: 3px; background: var(--accent); opacity: 0.55; flex: none; }
.gapnum { min-width: 4.4em; display: inline-block; }
.tbl-note { margin: 9px 0 0; font-size: 0.85rem; color: var(--muted); }
.tbl-note b { color: var(--ink); font-weight: 600; font-family: var(--font-mono); }
/* ---------- footer ---------- */
.foot {
margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--line);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--muted);
display: flex; justify-content: space-between; flex-wrap: wrap; gap: 8px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important; animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}
@media (max-width: 460px) {
.wrap { padding: 14px 11px 24px; }
.head h1 { font-size: 1.14rem; }
.sum-badge .val { font-size: 1.9rem; }
.tray { gap: 6px; }
/* narrow screens: drop the gap sparkbar so the gap number itself still fits */
table { font-size: 0.7rem; }
thead th, tbody td { padding-left: 6px; padding-right: 6px; }
.gapbar { display: none; }
.gapnum { min-width: 3.9em; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="head">
<div>
<h1>Dice Probability Lab</h1>
<p class="lede">Roll a handful of dice over and over. The bars are what actually happened; the line is what the math says should happen. Keep rolling and the bars climb onto the line.</p>
</div>
<div class="status" id="status" data-state="idle">
<span class="dot" aria-hidden="true"></span>
<span id="statusText">250 rolls</span>
</div>
</header>
<main class="grid">
<section class="panel controls" aria-labelledby="ctlH">
<h2 id="ctlH">Controls</h2>
<div style="margin-bottom:14px">
<span class="picker-legend" id="diceLbl">How many dice</span>
<div class="picker" role="group" aria-labelledby="diceLbl" id="picker"></div>
</div>
<div>
<span class="grp-legend">Roll them</span>
<div class="rollrow">
<button type="button" id="b1" class="primary">roll</button>
<button type="button" id="b100">×100</button>
<button type="button" id="b1k">×1000</button>
</div>
<div class="utilrow">
<button type="button" id="bauto" aria-pressed="false">auto roll</button>
<button type="button" id="bclear">clear</button>
</div>
</div>
<p class="hint" id="hint">tip: hit ×1000 a few times and watch<br>the gap collapse. then try 1 die, and 6.</p>
</section>
<div class="col">
<section class="panel" aria-labelledby="trayH">
<h2 id="trayH">The roll</h2>
<div class="tray-wrap">
<div class="tray" id="tray" aria-hidden="true"></div>
<div class="sum-badge">
<span class="eq" aria-hidden="true">=</span>
<div>
<div class="val" id="sumVal">7</div>
<div class="lab">SUM</div>
</div>
</div>
</div>
<p class="sr" id="live" role="status" aria-live="polite"></p>
</section>
<dl class="stats">
<div class="stat"><dt>Rolls</dt><dd id="s-rolls">—</dd></div>
<div class="stat"><dt>Average sum</dt><dd id="s-mean">—<small id="s-mean-sub"> </small></dd></div>
<div class="stat"><dt>Most common</dt><dd id="s-mode">—<small id="s-mode-sub"> </small></dd></div>
<div class="stat"><dt>Biggest gap</dt><dd id="s-gap">—<small id="s-gap-sub"> </small></dd></div>
</dl>
<section class="panel" aria-labelledby="chartH">
<div class="chart-head">
<h2 id="chartH">Sums · observed vs theory</h2>
<div class="legend">
<span><i class="swatch sw-obs" aria-hidden="true"></i>observed</span>
<span><i class="swatch sw-th" aria-hidden="true"></i>theory</span>
</div>
</div>
<canvas id="chart" tabindex="0" role="img"
aria-label="Histogram of dice sums with the theoretical distribution drawn over it. Use the left and right arrow keys to read each sum."></canvas>
<div class="readout mono" id="readout" role="status" aria-live="polite"></div>
<p class="insight" id="insight"></p>
</section>
<section class="panel" aria-labelledby="convH">
<div class="chart-head">
<h2 id="convH">Gap shrinking as rolls pile up</h2>
<div class="legend"><span>log scale · goal ≤ 1.00 pp</span></div>
</div>
<canvas id="spark" aria-hidden="true"></canvas>
</section>
<section class="panel" aria-labelledby="tblH">
<h2 id="tblH">Expected vs observed</h2>
<div class="tbl-scroll">
<table>
<caption class="sr">Every possible sum with the number of dice combinations that make it, the theoretical share of rolls, the observed share, and the gap between them.</caption>
<thead>
<tr>
<th scope="col">Sum</th>
<th scope="col">Ways</th>
<th scope="col">Theory</th>
<th scope="col">Observed</th>
<th scope="col">Gap</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<p class="tbl-note" id="tblNote"></p>
</section>
</div>
</main>
<footer class="foot">
<span>demo · sloprun.dev</span>
<span id="footRight">—</span>
</footer>
</div>
<script>
(function () {
"use strict";
/* ------------------------------------------------------------------
* State
* ------------------------------------------------------------------ */
var MAX_ROLLS = 2000000;
var GOAL_PP = 1.0; /* biggest-gap goal, in percentage points */
var MIN_ROLLS_OK = 2000; /* don't call it converged on a lucky few */
var SEED_ROLLS = 250;
var N = 2; /* dice count */
var bins = 0; /* number of distinct sums */
var ways = []; /* combinations per sum */
var waysTotal = 0; /* 6^N */
var expected = []; /* theoretical probability per sum */
var counts = []; /* observed count per sum */
var total = 0; /* rolls so far */
var lastDice = [];
var lastSum = 0;
var history = []; /* [rolls, gapPP] samples for the sparkline */
var nextSample = 0;
var cursor = -1; /* selected bin index, -1 = none */
var flashBin = -1;
var flashTimer = 0;
var pending = 0, perFrame = 0, auto = false, autoRate = 0, raf = 0;
var growT = 1, growRaf = 0;
var spin = null;
var lastTablePaint = 0;
var RM = window.matchMedia("(prefers-reduced-motion: reduce)");
function reduced() { return RM.matches; }
var $ = function (id) { return document.getElementById(id); };
var elTray = $("tray"), elSum = $("sumVal"), elLive = $("live");
var elStatus = $("status"), elStatusText = $("statusText");
var elChart = $("chart"), elSpark = $("spark");
var elReadout = $("readout"), elInsight = $("insight");
var elTbody = $("tbody"), elTblNote = $("tblNote"), elFootRight = $("footRight");
var diceEls = [];
var rowEls = [];
/* ------------------------------------------------------------------
* Theory: exact distribution of the sum of N six-sided dice
* ------------------------------------------------------------------ */
function buildTheory(n) {
var w = [1], i, f, nw;
for (var d = 0; d < n; d++) {
nw = new Array(w.length + 5);
for (i = 0; i < nw.length; i++) nw[i] = 0;
for (i = 0; i < w.length; i++) {
for (f = 0; f < 6; f++) nw[i + f] += w[i];
}
w = nw;
}
ways = w;
bins = w.length;
waysTotal = Math.pow(6, n);
expected = new Array(bins);
for (i = 0; i < bins; i++) expected[i] = ways[i] / waysTotal;
}
function sumOf(i) { return N + i; }
/* ------------------------------------------------------------------
* Rolling
* ------------------------------------------------------------------ */
function rollBulk(k) {
if (k < 1) return;
var n = N, s = lastSum, i, d, v;
for (i = 0; i < k; i++) {
s = 0;
for (d = 0; d < n; d++) { v = (Math.random() * 6 | 0) + 1; lastDice[d] = v; s += v; }
counts[s - n]++;
}
total += k;
lastSum = s;
}
function randomFaces() {
var a = [], d;
for (d = 0; d < N; d++) a.push((Math.random() * 6 | 0) + 1);
return a;
}
/* ------------------------------------------------------------------
* Stats
* ------------------------------------------------------------------ */
function stats() {
var gap = 0, gapAt = 0, mean = 0, mode = 0, modeC = -1, i, obs, g;
for (i = 0; i < bins; i++) {
obs = total ? counts[i] / total : 0;
g = Math.abs(obs - expected[i]);
if (g > gap) { gap = g; gapAt = i; }
mean += counts[i] * sumOf(i);
if (counts[i] > modeC) { modeC = counts[i]; mode = i; }
}
mean = total ? mean / total : 0;
return {
gapPP: gap * 100, gapAt: gapAt, mean: mean, mode: mode, modeC: modeC,
ok: total >= MIN_ROLLS_OK && gap * 100 <= GOAL_PP
};
}
function theoryMode() {
var best = -1, at = 0, ties = 0, i;
for (i = 0; i < bins; i++) {
if (ways[i] > best) { best = ways[i]; at = i; ties = 1; }
else if (ways[i] === best) ties++;
}
return { at: at, ways: best, ties: ties };
}
/* ------------------------------------------------------------------
* Formatting
* ------------------------------------------------------------------ */
function num(n) { return n.toLocaleString("en-US"); }
function pct(p) {
var v = p * 100;
if (v > 0 && v < 0.01) return v.toPrecision(2) + "%"; /* 1-in-46,656 is not 0.00% */
return v.toFixed(2) + "%";
}
function signed(d) { /* pp delta, never "−0.00" */
var a = Math.abs(d);
return (a < 0.005 ? "" : (d >= 0 ? "+" : "−")) + a.toFixed(2);
}
function pp(x) { return x.toFixed(2) + " pp"; }
/* ------------------------------------------------------------------
* Canvas helpers
* ------------------------------------------------------------------ */
function cssVar(name, fallback) {
var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
function rgba(hex, a) {
var h = hex.replace("#", "");
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
var n = parseInt(h, 16);
if (isNaN(n)) return hex;
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
function fit(cv, h) {
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var w = cv.clientWidth || (cv.parentNode ? cv.parentNode.clientWidth : 320) || 320;
cv.style.height = h + "px";
cv.width = Math.max(1, Math.round(w * dpr));
cv.height = Math.max(1, Math.round(h * dpr));
var ctx = cv.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
return { ctx: ctx, w: w, h: h };
}
function niceStep(raw) {
if (raw <= 0) return 1;
var p = Math.pow(10, Math.floor(Math.log(raw) / Math.LN10));
var m = raw / p;
var s = m <= 1 ? 1 : m <= 2 ? 2 : m <= 5 ? 5 : 10;
return s * p;
}
var geom = null; /* chart geometry, kept for pointer hit-testing */
/* ------------------------------------------------------------------
* Chart: histogram + theory line
* ------------------------------------------------------------------ */
function drawChart() {
var narrow = window.innerWidth < 560;
var H = narrow ? 218 : 288;
var c = fit(elChart, H), ctx = c.ctx, W = c.w;
if (!bins) return;
var ink = cssVar("--ink", "#1A1D21"), muted = cssVar("--muted", "#5A6068");
var line = cssVar("--line", "#E3E2DC"), accent = cssVar("--accent", "#C05600");
var surface = cssVar("--surface", "#FFFFFF");
var mono = cssVar("--font-mono", "monospace");
var padL = narrow ? 34 : 42, padR = 8, padT = 12, padB = 26;
var pw = Math.max(10, W - padL - padR), ph = Math.max(10, H - padT - padB);
var maxE = 0, maxO = 0, i;
for (i = 0; i < bins; i++) {
if (expected[i] > maxE) maxE = expected[i];
if (total && counts[i] / total > maxO) maxO = counts[i] / total;
}
var yMax = Math.max(maxE, maxO) * 1.18;
if (yMax <= 0) yMax = 0.2;
var step = pw / bins;
var bw = Math.max(2, step * 0.76);
function X(i) { return padL + (i + 0.5) * step; }
function Y(p) { return padT + ph - (p / yMax) * ph; }
/* grid */
var gs = niceStep(yMax / 4);
var gdec = gs * 100 < 1 ? 1 : 0; /* one decimals rule for the whole axis */
ctx.font = "11px " + mono;
ctx.textBaseline = "middle";
ctx.textAlign = "right";
for (var g = 0; g <= yMax + 1e-9; g += gs) {
var y = Y(g);
if (y < padT - 1) break;
ctx.strokeStyle = rgba(line, 0.95);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, Math.round(y) + 0.5);
ctx.lineTo(padL + pw, Math.round(y) + 0.5);
ctx.stroke();
ctx.fillStyle = muted;
ctx.fillText((g * 100).toFixed(gdec) + "%", padL - 6, y);
}
/* cursor band */
if (cursor >= 0 && cursor < bins) {
ctx.fillStyle = rgba(ink, 0.06);
ctx.fillRect(padL + cursor * step, padT, step, ph);
}
/* observed bars */
for (i = 0; i < bins; i++) {
var p = total ? counts[i] / total : 0;
var hgt = (p / yMax) * ph * growT;
if (hgt < 0.6) continue;
var x = X(i) - bw / 2;
var y0 = padT + ph - hgt;
var hot = (i === cursor) || (i === flashBin);
ctx.fillStyle = rgba(accent, hot ? 0.42 : 0.24);
ctx.fillRect(x, y0, bw, hgt);
ctx.strokeStyle = rgba(accent, hot ? 1 : 0.72);
ctx.lineWidth = hot ? 2 : 1;
ctx.strokeRect(Math.round(x) + 0.5, Math.round(y0) + 0.5, Math.round(bw) - 1, Math.round(hgt));
}
/* theory line */
ctx.strokeStyle = ink;
ctx.lineWidth = 2;
ctx.lineJoin = "round";
ctx.beginPath();
for (i = 0; i < bins; i++) {
var xx = X(i), yy = Y(expected[i]);
if (i === 0) ctx.moveTo(xx, yy); else ctx.lineTo(xx, yy);
}
ctx.stroke();
for (i = 0; i < bins; i++) {
ctx.beginPath();
ctx.arc(X(i), Y(expected[i]), step > 14 ? 3 : 2.2, 0, 6.2832);
ctx.fillStyle = ink;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = surface;
ctx.stroke();
}
/* baseline */
ctx.strokeStyle = rgba(muted, 0.55);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, Math.round(padT + ph) + 0.5);
ctx.lineTo(padL + pw, Math.round(padT + ph) + 0.5);
ctx.stroke();
/* x labels */
var lblStep = bins <= 12 ? 1 : bins <= 20 ? 2 : 3;
ctx.textAlign = "center";
ctx.textBaseline = "top";
ctx.font = "11px " + mono;
for (i = 0; i < bins; i++) {
if (i % lblStep !== 0 && i !== cursor) continue;
ctx.fillStyle = (i === cursor) ? accent : muted;
ctx.fillText(String(sumOf(i)), X(i), padT + ph + 7);
}
geom = { padL: padL, padT: padT, pw: pw, ph: ph, step: step };
}
/* ------------------------------------------------------------------
* Sparkline: biggest gap vs number of rolls (log x)
* ------------------------------------------------------------------ */
function drawSpark(st) {
var H = 76;
var c = fit(elSpark, H), ctx = c.ctx, W = c.w;
var ink = cssVar("--ink", "#1A1D21"), muted = cssVar("--muted", "#5A6068");
var line = cssVar("--line", "#E3E2DC"), accent = cssVar("--accent", "#C05600");
var run = cssVar("--run", "#1A7F37"), surface = cssVar("--surface", "#FFFFFF");
var mono = cssVar("--font-mono", "monospace");
var padL = 40, padR = 8, padT = 10, padB = 16;
var pw = Math.max(10, W - padL - padR), ph = Math.max(10, H - padT - padB);
var maxG = GOAL_PP * 1.6, i;
for (i = 0; i < history.length; i++) if (history[i][1] > maxG) maxG = history[i][1];
maxG = maxG * 1.1;
var x0 = Math.log(10) / Math.LN10;
var x1 = Math.log(Math.max(1000, total)) / Math.LN10;
function X(n) {
var lv = Math.log(Math.max(10, n)) / Math.LN10;
return padL + ((lv - x0) / Math.max(0.001, x1 - x0)) * pw;
}
function Y(g) { return padT + ph - (Math.min(g, maxG) / maxG) * ph; }
/* frame */
ctx.strokeStyle = rgba(line, 1);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(padL, Math.round(padT + ph) + 0.5);
ctx.lineTo(padL + pw, Math.round(padT + ph) + 0.5);
ctx.stroke();
/* goal line */
var gy = Y(GOAL_PP);
ctx.save();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = rgba(muted, 0.8);
ctx.beginPath();
ctx.moveTo(padL, Math.round(gy) + 0.5);
ctx.lineTo(padL + pw, Math.round(gy) + 0.5);
ctx.stroke();
ctx.restore();
ctx.font = "10px " + mono;
ctx.fillStyle = muted;
ctx.textAlign = "right";
ctx.textBaseline = "middle";
ctx.fillText("1.0pp", padL - 5, gy);
ctx.fillText(maxG.toFixed(1), padL - 5, padT + 3);
/* empty state: an axis with no trace on it reads as broken */
if (history.length < 2) {
var msg = total ? "trace starts at 10 rolls" : "no rolls yet — hit roll";
var cx = padL + pw / 2, cy = padT + ph / 2;
ctx.font = "11px " + mono;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
var tw = ctx.measureText(msg).width;
ctx.fillStyle = surface; /* knock the goal dashes out behind it */
ctx.fillRect(cx - tw / 2 - 6, cy - 8, tw + 12, 16);
ctx.fillStyle = muted;
ctx.fillText(msg, cx, cy);
}
/* series */
if (history.length > 1) {
ctx.strokeStyle = accent;
ctx.lineWidth = 1.75;
ctx.lineJoin = "round";
ctx.beginPath();
for (i = 0; i < history.length; i++) {
var px = X(history[i][0]), py = Y(history[i][1]);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.stroke();
var lx = X(total), ly = Y(st.gapPP);
ctx.beginPath();
ctx.arc(lx, ly, 3.4, 0, 6.2832);
ctx.fillStyle = st.ok ? run : accent;
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = surface;
ctx.stroke();
}
ctx.font = "10px " + mono;
ctx.fillStyle = muted;
ctx.textBaseline = "top";
ctx.textAlign = "left";
ctx.fillText("10", padL, padT + ph + 3);
ctx.textAlign = "right";
ctx.fillText(num(Math.max(1000, total)) + " rolls", padL + pw, padT + ph + 3);
}
/* ------------------------------------------------------------------
* Dice rendering
* ------------------------------------------------------------------ */
var PIPS = {
1: [4], 2: [0, 8], 3: [0, 4, 8],
4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8]
};
function buildTray() {
elTray.textContent = "";
diceEls = [];
for (var d = 0; d < N; d++) {
var die = document.createElement("div");
die.className = "die";
for (var i = 0; i < 9; i++) die.appendChild(document.createElement("i"));
elTray.appendChild(die);
diceEls.push(die);
}
}
function paintFaces(faces) {
for (var d = 0; d < diceEls.length; d++) {
var f = faces[d] || 1, cells = diceEls[d].children, on = PIPS[f];
for (var i = 0; i < 9; i++) cells[i].className = "";
for (var k = 0; k < on.length; k++) cells[on[k]].className = "on";
}
}
/* ------------------------------------------------------------------
* Table
* ------------------------------------------------------------------ */
function buildTable() {
elTbody.textContent = "";
rowEls = [];
for (var i = 0; i < bins; i++) {
var tr = document.createElement("tr");
var cells = [];
for (var c = 0; c < 5; c++) {
var td = document.createElement("td");
if (c === 1) td.className = "dim";
tr.appendChild(td);
cells.push(td);
}
var wrapc = document.createElement("span");
wrapc.className = "gapcell";
var bar = document.createElement("span");
bar.className = "gapbar";
var nums = document.createElement("span");
nums.className = "gapnum";
wrapc.appendChild(bar);
wrapc.appendChild(nums);
cells[4].appendChild(wrapc);
(function (idx) {
tr.addEventListener("click", function () { setCursor(idx); });
})(i);
elTbody.appendChild(tr);
rowEls.push({ tr: tr, c: cells, bar: bar, nums: nums });
}
var tm = theoryMode();
if (N === 1) {
elTblNote.innerHTML = "One die: every face is equally likely, <b>1 way in 6</b> each. The bars should end up flat.";
} else {
elTblNote.innerHTML = "<b>" + sumOf(tm.at) + "</b> is the most likely sum because <b>" + num(tm.ways) +
"</b> of the " + num(waysTotal) + " possible dice combinations add up to it. The extremes have exactly one combination each.";
}
}
function paintTable(st) {
var maxGapPct = Math.max(st.gapPP, 0.6);
for (var i = 0; i < bins; i++) {
var r = rowEls[i];
var obs = total ? counts[i] / total : 0;
var d = (obs - expected[i]) * 100;
r.c[0].textContent = String(sumOf(i));
r.c[1].textContent = num(ways[i]);
r.c[2].textContent = pct(expected[i]);
r.c[3].textContent = total ? pct(obs) : "—";
r.nums.textContent = total ? signed(d) : "—";
r.bar.style.width = total ? (Math.abs(d) / maxGapPct * 34).toFixed(1) + "px" : "0px";
if ((i === cursor) !== r.tr.classList.contains("sel")) r.tr.classList.toggle("sel", i === cursor);
}
}
/* ------------------------------------------------------------------
* Readout + insight + status
* ------------------------------------------------------------------ */
function paintReadout() {
if (cursor < 0 || cursor >= bins) {
elReadout.innerHTML = '<span class="k">tap or hover a bar — or press ← → — to inspect any sum</span>';
return;
}
var i = cursor, obs = total ? counts[i] / total : 0;
var d = (obs - expected[i]) * 100;
elReadout.innerHTML =
'<span>sum <b>' + sumOf(i) + '</b></span>' +
'<span><span class="k">ways</span> ' + num(ways[i]) + '/' + num(waysTotal) + '</span>' +
'<span><span class="k">theory</span> ' + pct(expected[i]) + '</span>' +
'<span><span class="k">observed</span> ' + (total ? pct(obs) + ' (' + num(counts[i]) + ')' : '—') + '</span>' +
'<span><span class="k">gap</span> ' + (total ? signed(d) + ' pp' : '—') + '</span>';
}
function paintInsight(st) {
if (total === 0) {
elInsight.innerHTML = "No rolls yet — the line is still there, because probability does not need to be measured to exist. Hit <b>roll</b>.";
return;
}
if (st.ok) {
elInsight.innerHTML = "After <b>" + num(total) + "</b> rolls every bar sits within <b>" + pp(st.gapPP) +
"</b> of theory. That is the law of large numbers: no single roll is predictable, but the shape of a great many of them is.";
return;
}
if (total < 400) {
elInsight.innerHTML = "Only <b>" + num(total) + "</b> roll" + (total === 1 ? "" : "s") +
" so far, so the picture is lumpy — the worst bar is <b>" + pp(st.gapPP) +
"</b> from the line. Randomness looks messy up close.";
return;
}
elInsight.innerHTML = "<b>" + num(total) + "</b> rolls in, the worst bar is <b>" + pp(st.gapPP) +
"</b> from the line. Keep going — the error shrinks roughly with the square root of the number of rolls.";
}
function paintStatus(st) {
if (pending > 0 || auto) {
elStatus.setAttribute("data-state", "run");
elStatusText.textContent = "rolling · " + num(total);
return;
}
if (st.ok) {
elStatus.setAttribute("data-state", "ok");
elStatusText.textContent = "converged · gap " + st.gapPP.toFixed(2) + "pp";
return;
}
elStatus.setAttribute("data-state", "idle");
elStatusText.textContent = num(total) + " roll" + (total === 1 ? "" : "s");
}
/* ------------------------------------------------------------------
* Refresh everything
* ------------------------------------------------------------------ */
function sample(st) {
if (total < nextSample) return;
history.push([total, (st || stats()).gapPP]);
if (history.length > 800) history.splice(0, 400);
nextSample = Math.max(total + 1, Math.ceil(total * 1.1));
}
function refresh(force) {
var st = stats();
sample(st);
paintFaces(lastDice);
elSum.textContent = total ? String(lastSum) : "—";
$("s-rolls").textContent = num(total);
$("s-mean").firstChild.nodeValue = total ? st.mean.toFixed(3) : "—";
$("s-mean-sub").textContent = "expect " + (3.5 * N).toFixed(2);
$("s-mode").firstChild.nodeValue = total ? String(sumOf(st.mode)) : "—";
var tm = theoryMode();
$("s-mode-sub").textContent = total
? num(st.modeC) + " times · " + (tm.ties > 1 ? "all sums tied" : "theory " + sumOf(tm.at))
: " ";
var gapEl = $("s-gap");
gapEl.firstChild.nodeValue = total ? st.gapPP.toFixed(2) + "pp" : "—";
gapEl.classList.toggle("good", !!st.ok);
$("s-gap-sub").textContent = st.ok ? "within goal" : "goal ≤ 1.00pp";
var now = performance.now();
if (force || now - lastTablePaintSafe() > 90) { paintTable(st); lastTablePaint = now; }
drawChart();
drawSpark(st);
paintReadout();
paintInsight(st);
paintStatus(st);
elFootRight.textContent = N + (N === 1 ? " die" : " dice") + " · " + num(waysTotal) + " combinations · " + bins + " sums";
return st;
}
function lastTablePaintSafe() { return lastTablePaint; }
/* ------------------------------------------------------------------
* Animation driver
* ------------------------------------------------------------------ */
function ensureRaf() { if (!raf) raf = requestAnimationFrame(tick); }
function stopRaf() { if (raf) { cancelAnimationFrame(raf); raf = 0; } }
function tick() {
raf = 0;
var did = false;
if (pending > 0) {
var k = Math.min(pending, perFrame);
rollBulk(k);
pending -= k;
did = true;
}
if (auto && pending <= 0) {
autoRate = Math.min(900, Math.round(autoRate * 1.09) + 2);
rollBulk(autoRate);
did = true;
if (total >= MAX_ROLLS) setAuto(false);
}
if (did) refresh();
if (pending > 0 || auto) ensureRaf();
else { refresh(true); announceBulk(); }
}
function queueRolls(k) {
finishSpin(true);
if (reduced()) {
rollBulk(k);
refresh(true);
announceBulk();
return;
}
pending += k;
perFrame = Math.max(1, Math.ceil(pending / 34));
ensureRaf();
}
function setAuto(on) {
auto = !!on;
$("bauto").setAttribute("aria-pressed", auto ? "true" : "false");
$("bauto").textContent = auto ? "stop" : "auto roll";
if (auto) { finishSpin(true); autoRate = 4; ensureRaf(); }
else { refresh(true); }
}
function animateGrow() {
if (reduced()) { growT = 1; return; }
if (growRaf) cancelAnimationFrame(growRaf);
var t0 = performance.now();
growT = 0;
(function step(now) {
var u = Math.min(1, (now - t0) / 420);
growT = 1 - Math.pow(1 - u, 3);
drawChart();
if (u < 1) growRaf = requestAnimationFrame(step);
else { growRaf = 0; growT = 1; }
})(t0);
}
/* ------------------------------------------------------------------
* Single animated roll
* ------------------------------------------------------------------ */
function finishSpin(silent) {
if (!spin) return;
clearInterval(spin.iv);
clearTimeout(spin.to);
spin = null;
for (var i = 0; i < diceEls.length; i++) diceEls[i].classList.remove("roll");
rollBulk(1);
refresh(true);
if (!silent) { flash(); announceRoll(); }
}
function rollOnce() {
if (reduced() || pending > 0 || auto) {
rollBulk(1);
refresh(true);
flash();
announceRoll();
return;
}
finishSpin(true);
var o = {};
spin = o;
for (var i = 0; i < diceEls.length; i++) {
var d = diceEls[i];
d.classList.remove("roll");
void d.offsetWidth;
d.style.animationDelay = (i * 28) + "ms";
d.classList.add("roll");
}
elSum.textContent = "·";
o.iv = setInterval(function () { paintFaces(randomFaces()); }, 62);
o.to = setTimeout(function () { if (spin === o) finishSpin(false); }, 420);
}
function flash() {
if (reduced()) return;
flashBin = lastSum - N;
drawChart();
clearTimeout(flashTimer);
flashTimer = setTimeout(function () { flashBin = -1; drawChart(); }, 300);
}
var lastAnnounced = -1;
function announceRoll() {
elLive.textContent = "Rolled " + lastDice.slice(0, N).join(" and ") + ". Sum " + lastSum + ". " +
num(total) + " roll" + (total === 1 ? "" : "s") + " total.";
lastAnnounced = total;
}
function announceBulk() {
/* nothing rolled yet: the clear handler owns the message, and a "biggest gap"
computed against zero observations is meaningless. */
if (!total || total === lastAnnounced) return;
var st = stats();
elLive.textContent = num(total) + " rolls total. Biggest gap between observed and theory is " + st.gapPP.toFixed(2) + " percentage points" + (st.ok ? ", within the goal." : ".");
lastAnnounced = total;
}
/* ------------------------------------------------------------------
* Cursor
* ------------------------------------------------------------------ */
function setCursor(i) {
if (i !== null && i >= 0) i = Math.max(0, Math.min(bins - 1, i));
if (i === cursor) return;
cursor = i;
drawChart();
paintReadout();
paintTable(stats());
}
function pointerBin(ev) {
if (!geom) return -1;
var r = elChart.getBoundingClientRect();
var x = (ev.touches ? ev.touches[0].clientX : ev.clientX) - r.left;
var i = Math.floor((x - geom.padL) / geom.step);
if (i < 0 || i >= bins) return -1;
return i;
}
/* ------------------------------------------------------------------
* Dice count
* ------------------------------------------------------------------ */
function setN(n, seed) {
finishSpin(true);
setAuto(false);
pending = 0;
stopRaf();
N = n;
lastDice = new Array(N);
for (var d = 0; d < N; d++) lastDice[d] = 1;
buildTheory(N);
counts = new Array(bins);
for (var i = 0; i < bins; i++) counts[i] = 0;
total = 0;
lastSum = N;
history = [];
nextSample = 10;
cursor = -1;
flashBin = -1;
lastAnnounced = -1;
buildTray();
buildTable();
var btns = $("picker").children;
for (var b = 0; b < btns.length; b++) btns[b].setAttribute("aria-pressed", (b + 1 === n) ? "true" : "false");
if (seed) {
while (total < SEED_ROLLS) {
rollBulk(Math.min(SEED_ROLLS - total, Math.max(1, Math.ceil(total * 0.12))));
sample();
}
}
refresh(true);
animateGrow();
}
/* ------------------------------------------------------------------
* Wiring
* ------------------------------------------------------------------ */
(function buildPicker() {
var p = $("picker");
for (var i = 1; i <= 6; i++) {
(function (n) {
var b = document.createElement("button");
b.type = "button";
b.textContent = String(n);
b.setAttribute("aria-pressed", "false");
b.setAttribute("aria-label", n + (n === 1 ? " die" : " dice"));
b.addEventListener("click", function () { if (N !== n) setN(n, true); });
p.appendChild(b);
})(i);
}
})();
$("b1").addEventListener("click", rollOnce);
$("b100").addEventListener("click", function () { queueRolls(100); });
$("b1k").addEventListener("click", function () { queueRolls(1000); });
$("bauto").addEventListener("click", function () { setAuto(!auto); });
$("bclear").addEventListener("click", function () {
finishSpin(true);
setAuto(false);
pending = 0;
stopRaf();
for (var i = 0; i < bins; i++) counts[i] = 0;
total = 0;
history = [];
nextSample = 10;
lastAnnounced = -1;
cursor = -1;
refresh(true);
elLive.textContent = "Cleared. 0 rolls.";
});
elChart.addEventListener("mousemove", function (e) { setCursor(pointerBin(e)); });
elChart.addEventListener("mouseleave", function () { setCursor(-1); });
var onTouch = function (e) {
var i = pointerBin(e);
if (i >= 0) setCursor(i);
};
elChart.addEventListener("touchstart", onTouch, { passive: true });
elChart.addEventListener("touchmove", onTouch, { passive: true });
elChart.addEventListener("keydown", function (e) {
var k = e.key, base = cursor < 0 ? Math.floor(bins / 2) : cursor;
if (k === "ArrowLeft") { setCursor(cursor < 0 ? base : Math.max(0, cursor - 1)); e.preventDefault(); }
else if (k === "ArrowRight") { setCursor(cursor < 0 ? base : cursor + 1); e.preventDefault(); }
else if (k === "Home") { setCursor(0); e.preventDefault(); }
else if (k === "End") { setCursor(bins - 1); e.preventDefault(); }
else if (k === "Escape") { setCursor(-1); }
});
elChart.addEventListener("blur", function () { setCursor(-1); });
/* theme + resize */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
drawChart();
drawSpark(stats());
}
});
var mq = window.matchMedia("(prefers-color-scheme: dark)");
var onScheme = function () { drawChart(); drawSpark(stats()); };
if (mq.addEventListener) mq.addEventListener("change", onScheme);
else if (mq.addListener) mq.addListener(onScheme);
var rz = 0, lastW = -1;
function onResize(force) {
if (rz) return;
rz = requestAnimationFrame(function () {
rz = 0;
var w = elChart.clientWidth;
if (!force && w === lastW) return;
lastW = w;
drawChart();
drawSpark(stats());
});
}
window.addEventListener("resize", function () { onResize(true); });
if (window.ResizeObserver) {
try {
new ResizeObserver(function () { onResize(false); }).observe(elChart.parentNode);
} catch (err) { /* older engines: window resize is enough */ }
}
/* ------------------------------------------------------------------
* Boot: never an empty room
* ------------------------------------------------------------------ */
setN(2, true);
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.