Galton Board
by sloprun seed
2026-08-14
0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
A bean machine you can watch think: every ball flips a coin at each peg row — left or right, twelve times, nothing else — so no single ball is predictable, yet the pile they build is the same bell every time. The curve laid over the bins is not fitted to the bars; it is drawn from the row count and the bounce odds alone, and the bars climb onto it as the counts grow. The readout keeps score honestly, showing the landed average and spread beside what the math predicts, and the share inside ±1σ beside the share the ideal curve puts there.
Try this first: hit Drop 100 a few times and watch the edge bins stay nearly empty — with 12 rows there are 4,096 equally likely paths down and exactly one lands in each far bin, so the edges take about 0.024% while the middle bin alone takes about 23%. Then drag Bounce right off 50% to slide and narrow the whole pile, and turn Animate off to skip the falling and fill each drop in instantly — that is also how the demo starts if your system asks for reduced motion.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Galton Board — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
: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); }
/* ── end tokens ───────────────────────────────────────────────────────────── */
/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.92); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.92); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.92); --shadow: 0 1px 2px rgba(0,0,0,.35); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }
.wrap { max-width: 1140px; margin: 0 auto; padding: 22px 16px 26px; display: flex; flex-direction: column; gap: 16px; }
h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.stage { display: grid; grid-template-columns: minmax(0,1fr) 302px; gap: 16px; align-items: start; }
@media (max-width: 900px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.field {
position: relative; overflow: hidden;
border: 1px solid var(--line); border-radius: var(--radius);
background: var(--surface); box-shadow: var(--shadow);
height: clamp(340px, calc(100vh - 300px), 580px);
}
@media (max-width: 900px) { .field { height: clamp(360px, 68vh, 520px); } }
canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: manipulation; outline: none; }
#board { cursor: crosshair; }
#board:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
.hint {
position: absolute; left: 10px; top: 10px; pointer-events: none; z-index: 2;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
color: var(--muted); background: var(--scrim);
border: 1px solid var(--line); border-radius: 999px; padding: 5px 10px;
transition: opacity .5s ease; max-width: calc(100% - 20px);
}
.hint.gone { opacity: 0; }
.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 9px 11px; }
.tbnote { font-size: 12px; color: var(--muted); margin-left: auto; }
.tbnote b { color: var(--ink); font-weight: 600; font-family: var(--font-mono); font-size: 11.5px; }
@media (max-width: 760px) { .tbnote { display: none; } }
.panel { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow); }
.card h2 {
margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 11px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v { font-family: var(--font-mono); font-size: 19px; line-height: 1.05; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .v.hi { color: var(--accent); }
.stat .k { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
/* Greek letters must survive text-transform: uppercase — σ must not become Σ */
.lc { text-transform: none; }
.pill {
display: inline-flex; align-items: center; gap: 6px; margin-top: 11px;
font-family: var(--font-mono); font-size: 11px; letter-spacing: .04em;
padding: 3px 9px 3px 7px; border-radius: 999px;
border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex: none; }
.pill.busy { color: var(--accent); background: var(--accent-soft); border-color: transparent; }
.pill.busy .dot { background: var(--accent); animation: blip 1s steps(2, end) infinite; }
.pill.ok { color: var(--run); background: var(--run-soft); border-color: transparent; }
.pill.ok .dot { background: var(--run); }
@keyframes blip { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.fline { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-top: 9px; }
.fline .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.fline .val { font-family: var(--font-mono); font-size: 12px; font-variant-numeric: tabular-nums; text-align: right; }
.fline .val.hi { color: var(--accent); }
.cap { margin: 10px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap code, code.m { font-family: var(--font-mono); font-size: .89em; color: var(--accent); background: var(--accent-soft); border-radius: 4px; padding: 1px 4px; }
/* controls */
.ctl + .ctl { margin-top: 13px; }
.ctl .row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; margin-bottom: 5px; }
.ctl .lbl { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); }
.ctl .val { font-family: var(--font-mono); font-size: 12.5px; color: var(--accent); font-variant-numeric: tabular-nums; }
input[type="range"] {
-webkit-appearance: none; appearance: none; width: 100%; height: 20px;
background: transparent; accent-color: var(--accent); cursor: pointer; margin: 0; display: block;
}
input[type="range"]:focus { outline: none; }
input[type="range"]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
input[type="range"]::-webkit-slider-runnable-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
box-shadow: 0 0 0 1px var(--accent);
}
input[type="range"]::-moz-range-track { height: 4px; border-radius: 999px; background: var(--line); }
input[type="range"]::-moz-range-thumb {
width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--accent);
}
/* buttons */
button {
font: inherit; font-size: 13px; font-family: var(--font-sans);
color: var(--ink); background: var(--surface);
border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; cursor: pointer; line-height: 1.2;
transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .38; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }
button.primary span { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.toggles { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; margin-top: 14px; }
.toggles button { display: inline-flex; align-items: center; gap: 6px; justify-content: center; font-size: 12px; padding: 7px 6px; }
.toggles button .sw { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; opacity: .45; }
.toggles button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); font-weight: 600; }
.toggles button[aria-pressed="true"] .sw { background: var(--accent); opacity: 1; }
/* inspector */
.insp-top { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.insp-n { font-family: var(--font-mono); font-size: 27px; line-height: 1; letter-spacing: -.03em; font-variant-numeric: tabular-nums; }
.badge {
font-family: var(--font-mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase;
padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); background: var(--bg);
}
.badge.hi { color: var(--accent); background: var(--accent-soft); border-color: transparent; font-weight: 600; }
.facts { margin: 11px 0 0; display: grid; grid-template-columns: auto minmax(0,1fr); gap: 5px 10px; }
.facts dt { font-family: var(--font-mono); font-size: 9.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--muted); padding-top: 2px; }
.facts dd { margin: 0; font-family: var(--font-mono); font-size: 12px; line-height: 1.35; word-break: break-word; font-variant-numeric: tabular-nums; }
.facts dd .dim { color: var(--muted); }
.legend { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 11px; }
.legend.off { opacity: .38; }
.lg { display: inline-flex; align-items: center; gap: 6px; font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); letter-spacing: .01em; }
.sw2 { width: 13px; height: 11px; flex: none; }
.prose { margin: 0; font-size: 12.5px; line-height: 1.6; color: var(--muted); }
.prose + .prose { margin-top: 9px; }
.prose b { color: var(--ink); font-weight: 600; }
.prose code { font-family: var(--font-mono); font-size: .9em; color: var(--accent); }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Galton Board</h1>
<p class="lede">
Balls fall through a triangle of pegs. Each peg is a coin flip — left or right, nothing else.
Do it a few thousand times and the pile at the bottom always draws the same shape:
<b>a bell curve</b>. No one aims the balls. The shape is just what randomness looks like when you stack it up.
</p>
</header>
<main class="stage">
<div class="left">
<div class="field" id="field">
<canvas id="board" tabindex="0" aria-label="Bean machine board. Balls fall through pegs into numbered bins. Use left and right arrow keys to inspect a bin.">
A triangle of pegs with balls falling into bins below. Your browser does not support canvas.
</canvas>
<div class="hint" id="hint">click a bin to inspect it</div>
</div>
<div class="card toolbar" role="group" aria-label="Drop controls">
<button type="button" class="primary" id="dropBtn">Drop <span id="dropN">250</span></button>
<button type="button" id="drop100">Drop 100</button>
<button type="button" id="settleBtn" disabled>Settle now</button>
<button type="button" id="resetBtn">Reset</button>
<span class="tbnote">Board is focusable: <b>Tab</b> to it, then <b>← →</b>.</span>
</div>
</div>
<div class="panel">
<section class="card" aria-label="Run readout">
<h2>Readout</h2>
<div class="stats">
<div class="stat"><span class="v" id="sTotal">0</span><span class="k">balls landed</span></div>
<div class="stat"><span class="v hi" id="sFlight">0</span><span class="k">still falling</span></div>
<div class="stat"><span class="v" id="sMean">—</span><span class="k">average bin</span></div>
<div class="stat"><span class="v" id="sSd">—</span><span class="k">spread <span class="lc">σ</span></span></div>
</div>
<span class="pill" id="pill" role="status"><span class="dot"></span><span id="pillText">idle</span></span>
<div class="fline"><span class="lbl">Curve predicts</span><span class="val" id="fPred">—</span></div>
<div class="fline"><span class="lbl">Inside ±1<span class="lc">σ</span></span><span class="val" id="fSigma">—</span></div>
<div class="fline"><span class="lbl">Tallest bin</span><span class="val hi" id="fTall">—</span></div>
<p class="cap">The badge turns green when the bars sit inside the wiggle room you would expect for this many balls. Small runs look lumpy — that is the point of dropping more.</p>
</section>
<section class="card" aria-label="Board settings">
<h2>The board</h2>
<div class="ctl">
<div class="row"><label class="lbl" for="rows">Peg rows</label><span class="val" id="vRows">12 → 13 bins</span></div>
<input type="range" id="rows" min="3" max="16" step="1" value="12" aria-describedby="rowsHelp">
</div>
<div class="ctl">
<div class="row"><label class="lbl" for="count">Balls per drop</label><span class="val" id="vCount">250</span></div>
<input type="range" id="count" min="10" max="1000" step="10" value="250">
</div>
<div class="ctl">
<div class="row"><label class="lbl" for="lean">Bounce right</label><span class="val" id="vLean">50%</span></div>
<input type="range" id="lean" min="30" max="70" step="1" value="50">
</div>
<div class="toggles" role="group" aria-label="Display options">
<button type="button" id="curveBtn" aria-pressed="true"><span class="sw" aria-hidden="true"></span>Bell curve</button>
<button type="button" id="animBtn" aria-pressed="true"><span class="sw" aria-hidden="true"></span>Animate</button>
</div>
<p class="cap" id="rowsHelp">Changing the rows or the lean <b>re-runs every ball that has landed</b> through the new board, so the histogram never goes blank. With <b>Animate</b> off, drops fill in instantly.</p>
</section>
<section class="card" aria-label="Bin inspector">
<h2>Inspect a bin</h2>
<div class="insp-top">
<span class="insp-n" id="iBin">bin 6</span>
<span class="badge" id="iBadge">—</span>
</div>
<dl class="facts">
<dt>Landed</dt><dd id="iCount">—</dd>
<dt>Curve says</dt><dd id="iExp">—</dd>
<dt>Routes in</dt><dd id="iRoutes">—</dd>
</dl>
<p class="cap" id="iCap">Click a bin, or focus the board and walk it with the arrow keys.</p>
</section>
<section class="card" aria-label="Why the bell curve appears">
<h2>Why a bell?</h2>
<p class="prose">
A ball's whole life is a string of coin flips: <code>L R R L R L ...</code>, one per row.
Where it lands depends only on <b>how many rights</b> it got, not which order they came in.
</p>
<p class="prose">
There is exactly <b>one</b> route to the far-left bin — go left every single time. But there
are thousands of routes into the middle, because there are thousands of ways to mix an equal
number of lefts and rights. The bins in the middle are simply easier to reach, and the bell
curve is that count of routes, drawn to scale.
</p>
<p class="prose">
Drag <b>bounce right</b> off 50% and the whole pile slides sideways and narrows — the same
machine, biased. That is the everyday version of this shape: heights, timings, measurement
errors, anything built from many small independent nudges.
</p>
</section>
</div>
</main>
<footer>demo · sloprun.dev</footer>
</div>
<p class="sr-only" id="live" aria-live="polite"></p>
<script>
(function () {
"use strict";
/* ── theme + motion plumbing ────────────────────────────────────────────── */
var root = document.documentElement;
var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
var mqRM = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
function reduced() { return !!(mqRM && mqRM.matches); }
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
root.setAttribute("data-theme", d.theme);
readColors(); draw();
}
});
if (mqDark) {
var onScheme = function () { readColors(); draw(); };
if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
else if (mqDark.addListener) mqDark.addListener(onScheme);
}
/* ── elements ───────────────────────────────────────────────────────────── */
var $ = function (id) { return document.getElementById(id); };
var field = $("field"), cv = $("board");
var ctx = null;
try { ctx = cv.getContext("2d"); } catch (e) { ctx = null; }
if (!ctx) {
/* Canvas is the whole demo — say so plainly instead of dying silently. */
var msg = document.createElement("p");
msg.className = "hint";
msg.style.cssText = "position:static;margin:12px;max-width:none;white-space:normal;border-radius:var(--radius)";
msg.textContent = "This browser could not open a 2D canvas, so the board cannot be drawn.";
cv.parentNode.replaceChild(msg, cv);
var hintEl = $("hint"); if (hintEl) hintEl.remove();
/* drop everything that would now be a dead control; keep the prose card */
var dead = document.querySelectorAll('.toolbar, [aria-label="Run readout"], [aria-label="Board settings"], [aria-label="Bin inspector"]');
for (var di = 0; di < dead.length; di++) dead[di].remove();
return;
}
var hint = $("hint"), live = $("live");
var sTotal = $("sTotal"), sFlight = $("sFlight"), sMean = $("sMean"), sSd = $("sSd");
var pill = $("pill"), pillText = $("pillText");
var fPred = $("fPred"), fSigma = $("fSigma"), fTall = $("fTall");
var dropBtn = $("dropBtn"), dropN = $("dropN"), drop100 = $("drop100"), settleBtn = $("settleBtn"), resetBtn = $("resetBtn");
var rowsIn = $("rows"), countIn = $("count"), leanIn = $("lean");
var vRows = $("vRows"), vCount = $("vCount"), vLean = $("vLean");
var curveBtn = $("curveBtn"), animBtn = $("animBtn");
var iBin = $("iBin"), iBadge = $("iBadge"), iCount = $("iCount"), iExp = $("iExp"), iRoutes = $("iRoutes"), iCap = $("iCap");
var MONO = 'ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace';
var MAX_TOTAL = 20000, MAX_FLIGHT = 300, G = 30;
/* ── colours read from the tokens ───────────────────────────────────────── */
var COL = { accent: "#C05600", ink: "#1A1D21", line: "#E3E2DC", muted: "#5A6068", surface: "#FFFFFF" };
var inkRGB = [26, 29, 33], accRGB = [192, 86, 0];
function cssVar(n, fb) {
var v = getComputedStyle(root).getPropertyValue(n);
v = v ? v.trim() : "";
return v || fb;
}
function hexToRgb(h) {
h = String(h).trim(); if (h.charAt(0) === "#") h = h.slice(1);
if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
var n = parseInt(h, 16);
if (!isFinite(n)) return [26, 29, 33];
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
function readColors() {
COL.accent = cssVar("--accent", "#C05600");
COL.ink = cssVar("--ink", "#1A1D21");
COL.line = cssVar("--line", "#E3E2DC");
COL.muted = cssVar("--muted", "#5A6068");
COL.surface = cssVar("--surface", "#FFFFFF");
inkRGB = hexToRgb(COL.ink); accRGB = hexToRgb(COL.accent);
}
function ink(a) { return "rgba(" + inkRGB[0] + "," + inkRGB[1] + "," + inkRGB[2] + "," + a + ")"; }
function acc(a) { return "rgba(" + accRGB[0] + "," + accRGB[1] + "," + accRGB[2] + "," + a + ")"; }
/* ── helpers ────────────────────────────────────────────────────────────── */
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function fmt(n) {
n = Math.round(n);
var neg = n < 0, s = String(Math.abs(n)), o = "", c = 0;
for (var i = s.length - 1; i >= 0; i--) { o = s.charAt(i) + o; if (++c % 3 === 0 && i > 0) o = "," + o; }
return (neg ? "-" : "") + o;
}
function setTxt(el, t) { if (el && el.textContent !== t) el.textContent = t; }
/* ── state ──────────────────────────────────────────────────────────────── */
var S = {
rows: 12, p: 0.5, perDrop: 250,
counts: new Int32Array(13), total: 0,
balls: [], queue: 0, rate: 40, acc: 0,
animate: !reduced(), showCurve: true,
sel: 6, scale: 1, settledOnce: false
};
var comb = [], pmf = [], cdf = [], combTotal = 1, pmfMode = 0;
var pegFlash = {}, lastFlash = -1e9;
function rebuildMath() {
var n = S.rows, i, k;
comb = [1];
for (i = 1; i <= n; i++) {
var next = [1];
for (k = 1; k < i; k++) next[k] = comb[k - 1] + comb[k];
next[i] = 1;
comb = next;
}
combTotal = Math.pow(2, n);
pmf = []; cdf = [];
var q = 1 - S.p, run = 0;
pmfMode = 0;
for (k = 0; k <= n; k++) {
pmf[k] = comb[k] * Math.pow(S.p, k) * Math.pow(q, n - k);
/* true binomial mode = argmax of the pmf. Math.round(n*p) is NOT it:
at n=12, p=0.3 the mode is bin 3 (0.2397) but round(3.6) says bin 4 (0.2311). */
if (pmf[k] > pmf[pmfMode]) pmfMode = k;
run += pmf[k]; cdf[k] = run;
}
cdf[n] = 1;
}
function sampleBin() {
var k = 0, p = S.p, n = S.rows;
for (var i = 0; i < n; i++) if (Math.random() < p) k++;
return k;
}
/* Same distribution as sampleBin, but one RNG draw + a binary search instead of
one draw per row — resampleAll runs on every slider input event, and at 20k
balls x 16 rows the per-row version burned ~320k draws per drag frame. */
function sampleBinFast() {
var u = Math.random(), lo = 0, hi = S.rows, mid;
while (lo < hi) { mid = (lo + hi) >> 1; if (u < cdf[mid]) hi = mid; else lo = mid + 1; }
return lo;
}
function resampleAll() {
var t = S.total, c = new Int32Array(S.rows + 1);
for (var i = 0; i < t; i++) c[sampleBinFast()]++;
S.counts = c;
}
/* ── geometry ───────────────────────────────────────────────────────────── */
var W = 0, H = 0, dpr = 1;
var cx = 0, pegTop = 0, rowGap = 20, spacing = 20, binBase = 0, binTop = 0, maxBarH = 40;
var ballR = 3, pegR = 2, entryV = -0.9;
function layout() {
/* Measure the CANVAS, not .field — .field's rect includes its 1px border, so
using it made the backing store 2px wider/taller than the element's CSS box.
That resampled every frame (blurry at dpr 1) and skewed click-to-bin mapping. */
var r = cv.getBoundingClientRect();
W = Math.max(1, r.width); H = Math.max(1, r.height);
dpr = Math.min(2, window.devicePixelRatio || 1);
var pw = Math.max(1, Math.round(W * dpr)), ph = Math.max(1, Math.round(H * dpr));
if (cv.width !== pw || cv.height !== ph) { cv.width = pw; cv.height = ph; }
/* scale by the real ratio so drawing fills the backing store exactly even when
the CSS box is fractional (pw/W is dpr up to sub-pixel rounding) */
ctx.setTransform(pw / W, 0, 0, ph / H, 0, 0);
var padX = 10;
cx = W / 2;
binBase = H - 18;
var binH = clamp(H * 0.30, 66, 168);
binTop = binBase - binH;
maxBarH = binH - 10;
pegTop = clamp(H * 0.14, 26, 90);
var pegBottom = binTop - 6;
rowGap = Math.max(6, (pegBottom - pegTop) / S.rows);
spacing = (W - 2 * padX) / (S.rows + 1);
ballR = clamp(spacing * 0.17, 1.7, 5.5);
pegR = clamp(spacing * 0.095, 1.1, 3.4);
entryV = -Math.min(0.9, Math.max(0.15, (pegTop - 8) / rowGap));
}
function X(u) { return cx + u * spacing; }
function Y(v) { return pegTop + v * rowGap; }
function binX(k) { return X(k - S.rows / 2); }
function binOfX(px) {
var u = (px - cx) / spacing;
return clamp(Math.round(u + S.rows / 2), 0, S.rows);
}
/* ── simulation ─────────────────────────────────────────────────────────── */
function targetScale() {
var m = 1;
for (var k = 0; k <= S.rows; k++) if (S.counts[k] > m) m = S.counts[k];
return maxBarH / m;
}
function surfaceY(k) {
return binBase - Math.min(S.counts[k] * S.scale, maxBarH);
}
function spawn() {
var b = {
u: (Math.random() - 0.5) * 0.05, v: entryV, vv: 1.2 + Math.random() * 0.9,
uu: 0, target: 0, targetU: 0, damp: 0.76 + Math.random() * 0.12,
phase: "peg", bin: 0, px: 0, py: 0, started: false
};
b.px = X(b.u); b.py = Y(b.v);
setSeg(b);
S.balls.push(b);
}
function setSeg(b) {
b.target = Math.floor(b.v + 1e-9) + 1;
var dv = b.target - b.v;
var vEnd = Math.sqrt(b.vv * b.vv + 2 * G * dv);
var t = (vEnd - b.vv) / G;
if (!(t > 0.0001)) t = 0.0001;
var du = b.started ? (Math.random() < S.p ? 0.5 : -0.5) : 0;
b.targetU = b.u + du;
b.uu = du / t;
}
function finishBall(b, k) {
k = clamp(k, 0, S.rows);
if (S.total < MAX_TOTAL) { S.counts[k]++; S.total++; }
}
function step(dt) {
/* release from the queue */
if (S.queue > 0) {
S.acc += S.rate * dt;
while (S.acc >= 1 && S.queue > 0 && S.balls.length < MAX_FLIGHT) { S.acc -= 1; S.queue--; spawn(); }
if (S.balls.length >= MAX_FLIGHT) S.acc = 0;
}
var n = S.rows, i, b;
for (i = S.balls.length - 1; i >= 0; i--) {
b = S.balls[i];
b.px = X(b.u); b.py = Y(b.v);
b.vv += G * dt;
b.v += b.vv * dt;
b.u += b.uu * dt;
if (b.phase === "peg") {
if (b.v >= b.target) {
b.v = b.target; b.u = Math.round(b.u * 2) / 2;
var lvl = b.target;
if (lvl >= n) {
b.phase = "fall"; b.uu = 0;
b.bin = clamp(Math.round(b.u + n / 2), 0, n);
b.u = b.bin - n / 2;
} else {
if (lvl >= 0) {
var pi = Math.round(b.u + lvl / 2);
if (pi >= 0 && pi <= lvl) { pegFlash[lvl * 64 + pi] = performance.now(); lastFlash = performance.now(); }
}
b.vv *= b.damp;
b.started = true;
setSeg(b);
}
} else if (b.target >= n) {
/* final hop — the pile may already be this high */
var kk = clamp(Math.round(b.targetU + n / 2), 0, n);
if (Y(b.v) + ballR >= surfaceY(kk)) { finishBall(b, kk); S.balls.splice(i, 1); continue; }
}
}
if (b.phase === "fall") {
if (Y(b.v) + ballR >= surfaceY(b.bin)) { finishBall(b, b.bin); S.balls.splice(i, 1); continue; }
if (b.v > n + 40) { finishBall(b, b.bin); S.balls.splice(i, 1); continue; }
}
}
var ts = targetScale();
if (S.animate) S.scale += (ts - S.scale) * Math.min(1, dt * 7);
else S.scale = ts;
if (Math.abs(ts - S.scale) < 0.0005) S.scale = ts;
}
function pending() { return S.balls.length + S.queue; }
/* ── drawing ────────────────────────────────────────────────────────────── */
function roundTop(x, y, w, h, r) {
r = Math.min(r, w / 2, h);
ctx.beginPath();
ctx.moveTo(x, y + h);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h);
ctx.closePath();
}
function draw() {
if (!W || !H) return;
var n = S.rows, k, i;
ctx.clearRect(0, 0, W, H);
/* hopper */
var eY = Y(entryV);
ctx.strokeStyle = COL.line; ctx.lineWidth = 1.5; ctx.lineCap = "round";
ctx.beginPath();
ctx.moveTo(cx - spacing * 1.7, Math.max(6, eY - 22)); ctx.lineTo(cx - ballR - 2.5, eY);
ctx.moveTo(cx + spacing * 1.7, Math.max(6, eY - 22)); ctx.lineTo(cx + ballR + 2.5, eY);
ctx.stroke();
/* bin floor + walls */
var wide = spacing >= 25;
ctx.strokeStyle = ink(0.10); ctx.lineWidth = 1;
ctx.beginPath();
for (k = 0; k <= n + 1; k++) {
var sx = Math.round(X(k - n / 2 - 0.5)) + 0.5;
ctx.moveTo(sx, binTop + 2); ctx.lineTo(sx, binBase);
}
ctx.stroke();
ctx.strokeStyle = ink(0.28); ctx.lineWidth = 1.25;
ctx.beginPath();
ctx.moveTo(X(-n / 2 - 0.5), Math.round(binBase) + 0.5);
ctx.lineTo(X(n / 2 + 0.5), Math.round(binBase) + 0.5);
ctx.stroke();
/* selected bin column tint */
var selL = X(S.sel - n / 2 - 0.5), selW = spacing;
ctx.fillStyle = acc(0.07);
ctx.fillRect(selL, binTop + 2, selW, binBase - binTop - 2);
/* bars */
var bw = Math.max(2, spacing * 0.84);
for (k = 0; k <= n; k++) {
var c = S.counts[k];
if (!c) continue;
var h = Math.min(c * S.scale, maxBarH);
if (h < 0.6) h = 0.6;
var bx = binX(k) - bw / 2, by = binBase - h;
ctx.fillStyle = k === S.sel ? acc(0.34) : acc(0.20);
roundTop(bx, by, bw, h, Math.min(3, bw / 2)); ctx.fill();
ctx.strokeStyle = acc(k === S.sel ? 1 : 0.85); ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(bx, by + 1); ctx.lineTo(bx + bw, by + 1); ctx.stroke();
}
/* the bell curve */
if (S.showCurve && S.total > 0) {
var mu = n * S.p, sd = Math.sqrt(n * S.p * (1 - S.p)) || 0.0001;
ctx.save();
ctx.beginPath(); ctx.rect(0, binTop - 2, W, binBase - binTop + 4); ctx.clip();
/* one-sigma band */
var l = X(mu - sd - n / 2), rr = X(mu + sd - n / 2);
ctx.fillStyle = ink(0.05);
ctx.fillRect(l, binTop - 2, rr - l, binBase - binTop + 2);
ctx.strokeStyle = ink(0.22); ctx.lineWidth = 1;
if (ctx.setLineDash) ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(Math.round(l) + 0.5, binTop); ctx.lineTo(Math.round(l) + 0.5, binBase);
ctx.moveTo(Math.round(rr) + 0.5, binTop); ctx.lineTo(Math.round(rr) + 0.5, binBase);
ctx.stroke();
if (ctx.setLineDash) ctx.setLineDash([]);
/* smooth normal curve */
var norm = 1 / (sd * Math.sqrt(2 * Math.PI));
ctx.beginPath();
var steps = 220;
for (i = 0; i <= steps; i++) {
var t = -0.7 + (n + 1.4) * (i / steps);
var d = norm * Math.exp(-((t - mu) * (t - mu)) / (2 * sd * sd));
var py = binBase - S.total * d * S.scale;
var px = X(t - n / 2);
if (i === 0) ctx.moveTo(px, py); else ctx.lineTo(px, py);
}
ctx.strokeStyle = ink(0.55); ctx.lineWidth = 2; ctx.lineJoin = "round";
ctx.stroke();
/* exact expected counts */
ctx.fillStyle = ink(0.30);
for (k = 0; k <= n; k++) {
var ey = binBase - S.total * pmf[k] * S.scale;
ctx.beginPath(); ctx.arc(binX(k), ey, 1.9, 0, 6.2832); ctx.fill();
}
ctx.restore();
if (W > 430) {
ctx.font = "10px " + MONO;
ctx.fillStyle = COL.muted; ctx.textAlign = "center"; ctx.textBaseline = "top";
ctx.fillText("-1σ", l, binTop - 13);
ctx.fillText("+1σ", rr, binTop - 13);
}
}
/* labels */
if (wide) {
ctx.font = "10px " + MONO; ctx.textAlign = "center";
ctx.textBaseline = "top";
for (k = 0; k <= n; k++) {
ctx.fillStyle = k === S.sel ? COL.accent : COL.muted;
ctx.fillText(String(k), binX(k), binBase + 4);
}
ctx.textBaseline = "bottom";
for (k = 0; k <= n; k++) {
if (!S.counts[k]) continue;
var hh = Math.min(S.counts[k] * S.scale, maxBarH);
var ly = binBase - hh - 4;
if (ly < binTop - 12) ly = binBase - hh + 14;
ctx.fillStyle = k === S.sel ? COL.accent : COL.muted;
ctx.fillText(fmt(S.counts[k]), binX(k), ly);
}
}
/* pegs */
var now = performance.now();
for (var r = 0; r < n; r++) {
var py2 = Y(r);
for (i = 0; i <= r; i++) {
var pxp = X(-r / 2 + i);
var f = pegFlash[r * 64 + i];
var age = f ? (now - f) / 260 : 2;
ctx.beginPath(); ctx.arc(pxp, py2, pegR + (age < 1 ? (1 - age) * 1.6 : 0), 0, 6.2832);
ctx.fillStyle = age < 1 ? acc(0.35 + 0.65 * (1 - age)) : ink(0.30);
ctx.fill();
}
}
/* balls */
if (S.balls.length) {
ctx.lineCap = "round";
for (i = 0; i < S.balls.length; i++) {
var b = S.balls[i], bx2 = X(b.u), by2 = Y(b.v);
ctx.strokeStyle = acc(0.20); ctx.lineWidth = ballR * 1.7;
ctx.beginPath(); ctx.moveTo(b.px, b.py); ctx.lineTo(bx2, by2); ctx.stroke();
ctx.fillStyle = COL.accent;
ctx.beginPath(); ctx.arc(bx2, by2, ballR, 0, 6.2832); ctx.fill();
if (ballR > 2.6) {
ctx.fillStyle = "rgba(255,255,255,.45)";
ctx.beginPath(); ctx.arc(bx2 - ballR * 0.3, by2 - ballR * 0.35, ballR * 0.32, 0, 6.2832); ctx.fill();
}
}
}
}
/* ── stats + UI ─────────────────────────────────────────────────────────── */
function stats() {
var n = S.rows, k, c, t = S.total;
if (!t) return null;
var mean = 0;
for (k = 0; k <= n; k++) mean += k * S.counts[k];
mean /= t;
var vr = 0;
for (k = 0; k <= n; k++) { var d = k - mean; vr += d * d * S.counts[k]; }
vr /= t;
var sd = Math.sqrt(vr);
/* Share landing inside ±1σ. Use the IDEAL band (mu ± sigma from the row count)
for both the observed and the expected share, so the two numbers compare
like for like. Comparing against the continuous 68% rule was wrong: bins are
whole numbers, so at 12 rows only bins 5-7 fall inside ±1σ and the ideal
share is 61.2% — the readout could never reach 68% no matter how many balls. */
var mu0 = n * S.p, sg0 = Math.sqrt(n * S.p * (1 - S.p));
var inside = 0, insideExp = 0;
for (k = 0; k <= n; k++) {
if (Math.abs(k - mu0) <= sg0 + 1e-9) { inside += S.counts[k]; insideExp += pmf[k]; }
}
var cm = 0, ce = 0, D = 0, tall = 0, tallK = 0;
for (k = 0; k <= n; k++) {
cm += S.counts[k] / t; ce += pmf[k];
var dd = Math.abs(cm - ce); if (dd > D) D = dd;
if (S.counts[k] > tall) { tall = S.counts[k]; tallK = k; }
}
return { mean: mean, sd: sd, inside: inside / t, insideExp: insideExp,
D: D, stat: D * Math.sqrt(t), tall: tall, tallK: tallK };
}
function updateUI() {
var n = S.rows, st = stats(), p = pending();
setTxt(sTotal, fmt(S.total));
setTxt(sFlight, fmt(p));
sFlight.className = p ? "v hi" : "v";
setTxt(sMean, st ? st.mean.toFixed(2) : "—");
setTxt(sSd, st ? st.sd.toFixed(2) : "—");
var mu = n * S.p, sd = Math.sqrt(n * S.p * (1 - S.p));
setTxt(fPred, "avg " + mu.toFixed(2) + " · σ " + sd.toFixed(2));
setTxt(fSigma, st ? (st.inside * 100).toFixed(1) + "% · curve says " + (st.insideExp * 100).toFixed(1) + "%" : "—");
setTxt(fTall, st ? "bin " + st.tallK + " · " + fmt(st.tall) : "—");
/* pill */
var cls = "pill", txt;
if (p > 0) { cls = "pill busy"; txt = "dropping · " + fmt(p) + " left"; }
else if (!S.total) { txt = "empty — drop some balls"; }
else if (S.total < 150) { txt = "only " + fmt(S.total) + " balls — lumpy"; }
else if (st && st.stat < 1.36) { cls = "pill ok"; txt = "matches the bell curve"; }
else { txt = "off the curve · drop more"; }
if (pill.className !== cls) pill.className = cls;
setTxt(pillText, txt);
dropBtn.disabled = S.total >= MAX_TOTAL;
drop100.disabled = S.total >= MAX_TOTAL;
settleBtn.disabled = p === 0;
resetBtn.disabled = S.total === 0 && p === 0;
updateInspector();
}
function updateInspector() {
var n = S.rows, k = S.sel, c = S.counts[k];
setTxt(iBin, "bin " + k);
var routes = comb[k] || 1;
var isEdge = (k === 0 || k === n);
if (isEdge) { iBadge.className = "badge"; setTxt(iBadge, routes === 1 ? "one route" : "edge"); }
else if (k === pmfMode) { iBadge.className = "badge hi"; setTxt(iBadge, "most likely"); }
else { iBadge.className = "badge"; setTxt(iBadge, k + " rights, " + (n - k) + " lefts"); }
setTxt(iCount, S.total ? fmt(c) + " " + (c / S.total * 100).toFixed(1) + "%" : "0");
setTxt(iExp, S.total ? fmt(S.total * pmf[k]) + " " + (pmf[k] * 100).toFixed(1) + "%" : "—");
setTxt(iRoutes, fmt(routes) + " of " + fmt(combTotal));
var pctR = routes / combTotal * 100;
setTxt(iCap, routes === 1
? "Exactly one path leads here: every single bounce has to go the same way."
: fmt(routes) + " different left-right sequences end in this bin — " + (pctR < 0.1 ? "under 0.1" : pctR.toFixed(1)) + "% of all " + fmt(combTotal) + " routes through the board.");
}
function say(t) { if (live) live.textContent = t; }
/* ── loop ───────────────────────────────────────────────────────────────── */
var raf = 0, last = 0;
function needAnim() {
return S.balls.length > 0 || S.queue > 0 ||
Math.abs(targetScale() - S.scale) > 0.0005 ||
(performance.now() - lastFlash) < 300;
}
function frame(t) {
raf = 0;
var dt = (t - last) / 1000;
if (!(dt > 0) || dt > 0.05) dt = 0.016;
last = t;
step(dt);
draw();
updateUI();
if (needAnim()) kick();
else if (S.settledOnce) { S.settledOnce = false; announceSettle(); }
}
function kick() {
if (raf) return;
last = performance.now();
raf = requestAnimationFrame(frame);
}
function refresh() {
if (needAnim()) kick();
else { S.scale = targetScale(); draw(); updateUI(); }
}
function announceSettle() {
var st = stats();
if (!st) return;
say(fmt(S.total) + " balls landed. Average bin " + st.mean.toFixed(2) + ", spread " + st.sd.toFixed(2) + ".");
}
/* ── actions ────────────────────────────────────────────────────────────── */
function drop(n) {
n = Math.min(n, MAX_TOTAL - S.total - pending());
if (n <= 0) return;
if (!S.animate) {
for (var i = 0; i < n; i++) S.counts[sampleBin()]++;
S.total += n;
hideHint();
refresh();
announceSettle();
return;
}
S.queue += n;
S.rate = clamp(n / 7, 10, 130);
S.settledOnce = true;
hideHint();
kick();
}
function settleNow() {
var i, b;
for (i = 0; i < S.balls.length; i++) {
b = S.balls[i];
var lvl = Math.max(0, Math.ceil(b.v));
var k = Math.round(b.targetU + S.rows / 2);
for (var r = lvl; r < S.rows; r++) if (Math.random() < S.p) k++;
finishBall(b, k);
}
S.balls.length = 0;
var q = S.queue; S.queue = 0; S.acc = 0;
for (i = 0; i < q; i++) { if (S.total >= MAX_TOTAL) break; S.counts[sampleBin()]++; S.total++; }
refresh();
announceSettle();
}
function resetAll() {
S.balls.length = 0; S.queue = 0; S.acc = 0;
S.counts = new Int32Array(S.rows + 1); S.total = 0;
S.scale = targetScale();
refresh();
say("Board cleared.");
}
function hideHint() { if (hint && !hint.classList.contains("gone")) hint.classList.add("gone"); }
function setRows(n) {
var carried = pending();
S.rows = n;
S.balls.length = 0; S.acc = 0;
S.sel = clamp(S.sel, 0, n);
rebuildMath();
resampleAll();
layout();
S.queue = S.animate ? carried : 0;
if (!S.animate && carried) { for (var i = 0; i < carried && S.total < MAX_TOTAL; i++) { S.counts[sampleBin()]++; S.total++; } }
setTxt(vRows, n + " → " + (n + 1) + " bins");
if (!S.animate) S.scale = targetScale();
refresh();
}
function setLean(pc) {
S.p = pc / 100;
rebuildMath();
resampleAll();
setTxt(vLean, pc + "%");
refresh();
}
/* ── events ─────────────────────────────────────────────────────────────── */
dropBtn.addEventListener("click", function () { drop(S.perDrop); });
drop100.addEventListener("click", function () { drop(100); });
settleBtn.addEventListener("click", settleNow);
resetBtn.addEventListener("click", resetAll);
rowsIn.addEventListener("input", function () { setRows(parseInt(rowsIn.value, 10)); });
countIn.addEventListener("input", function () {
S.perDrop = parseInt(countIn.value, 10);
setTxt(vCount, fmt(S.perDrop)); setTxt(dropN, fmt(S.perDrop));
});
leanIn.addEventListener("input", function () { setLean(parseInt(leanIn.value, 10)); });
curveBtn.addEventListener("click", function () {
S.showCurve = !S.showCurve;
curveBtn.setAttribute("aria-pressed", S.showCurve ? "true" : "false");
draw();
});
animBtn.addEventListener("click", function () {
S.animate = !S.animate;
animBtn.setAttribute("aria-pressed", S.animate ? "true" : "false");
if (!S.animate && pending()) settleNow();
else refresh();
say(S.animate ? "Animation on." : "Animation off: drops fill in instantly.");
});
function pick(e) {
var r = cv.getBoundingClientRect();
var px = (e.clientX !== undefined ? e.clientX : 0) - r.left;
S.sel = binOfX(px);
hideHint();
updateInspector();
draw();
}
cv.addEventListener("pointerdown", function (e) { pick(e); cv.focus(); });
cv.addEventListener("keydown", function (e) {
var k = e.key, n = S.rows, moved = true;
if (k === "ArrowLeft") S.sel = Math.max(0, S.sel - 1);
else if (k === "ArrowRight") S.sel = Math.min(n, S.sel + 1);
else if (k === "Home") S.sel = 0;
else if (k === "End") S.sel = n;
else moved = false;
if (!moved) return;
e.preventDefault();
hideHint();
updateInspector(); draw();
say("Bin " + S.sel + ": " + fmt(S.counts[S.sel]) + " balls, " + fmt(comb[S.sel]) + " routes in.");
});
/* resize */
function onResize() {
layout();
if (!S.animate) S.scale = targetScale();
draw();
}
if (window.ResizeObserver) {
var ro = new ResizeObserver(onResize);
ro.observe(field);
}
window.addEventListener("resize", onResize);
if (mqRM) {
var onRM = function () {
S.animate = !reduced();
animBtn.setAttribute("aria-pressed", S.animate ? "true" : "false");
if (!S.animate && pending()) settleNow(); else refresh();
};
if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
else if (mqRM.addListener) mqRM.addListener(onRM);
}
/* ── boot: never an empty board ─────────────────────────────────────────── */
readColors();
rebuildMath();
layout();
animBtn.setAttribute("aria-pressed", S.animate ? "true" : "false");
setTxt(vRows, S.rows + " → " + (S.rows + 1) + " bins");
setTxt(vCount, fmt(S.perDrop));
setTxt(dropN, fmt(S.perDrop));
setTxt(vLean, "50%");
S.sel = pmfMode;
/* seed a run that has already happened, then keep the machine alive */
for (var s = 0; s < 260; s++) S.counts[sampleBin()]++;
S.total = 260;
S.scale = targetScale();
draw(); updateUI();
if (S.animate) { S.queue = 190; S.rate = 34; S.settledOnce = true; kick(); }
else {
for (var s2 = 0; s2 < 190; s2++) S.counts[sampleBin()]++;
S.total += 190;
/* No iCap message here — refresh() runs updateInspector, which owns that line
and would overwrite it immediately. The "Animate" toggle reads as off and
the board-settings note already explains the instant-fill mode. */
refresh();
say("Reduced motion is on, so drops fill in instantly. Turn on Animate to watch the balls fall.");
}
})();
</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.