12

Birthday Paradox Simulator

Tap + / − to change days per year

This birthday paradox simulator runs hundreds of trials per minute: people land on a 365-day calendar ring until two share a birthday, the colliding day flashes red, and the group size feeds a live histogram. Watch the empirical P(match in a group of 23) converge on the theoretical , with the white curve showing the exact distribution. Tap the + and − buttons to change the number of days per year and see the typical collision point scale like .

idle
143 lines · vanilla
view source
const DAY_OPTS = [30, 60, 100, 183, 365, 500, 1000];
const HMAX = 120, NB = 80, BTN = 44, PAD = 12, TAU = Math.PI * 2;
let W, H, days, daysIdx, hist, trials, match23;
let occGen, gen, occVis, visGen, people, nPeople, addAcc, flashT, flashDay;
let pNoMatch, pmf, theory23;

function computeTheory() {
  pNoMatch[0] = 1; pNoMatch[1] = 1;
  for (let n = 2; n <= HMAX; n++) {
    pmf[n] = pNoMatch[n - 1] * Math.min(1, (n - 1) / days);
    pNoMatch[n] = pNoMatch[n - 1] * Math.max(0, (days - (n - 1)) / days);
  }
  theory23 = 1 - pNoMatch[23];
}

function startTrial() { nPeople = 0; visGen++; flashT = 0; flashDay = -1; addAcc = 0; }
function record(n) { hist[Math.min(n, HMAX)]++; trials++; if (n <= 23) match23++; }

function silentTrial() {
  gen++;
  for (let n = 1; ; n++) {
    const d = (Math.random() * days) | 0;
    if (occGen[d] === gen || n >= HMAX) { record(n); return; }
    occGen[d] = gen;
  }
}

function addPerson() {
  const d = (Math.random() * days) | 0;
  people[nPeople++] = d;
  if (occVis[d] === visGen || nPeople >= HMAX) { record(nPeople); flashT = 0.8; flashDay = d; }
  else occVis[d] = visGen;
}

function setDays(idx) {
  daysIdx = Math.max(0, Math.min(DAY_OPTS.length - 1, idx));
  days = DAY_OPTS[daysIdx];
  hist.fill(0); trials = 0; match23 = 0;
  computeTheory();
  startTrial();
  for (let i = 0; i < 300; i++) silentTrial();
}

function init({ ctx, width, height }) {
  W = width; H = height;
  hist = new Int32Array(HMAX + 1);
  occGen = new Int32Array(1000); gen = 0;
  occVis = new Int32Array(1000); visGen = 0;
  people = new Int16Array(HMAX + 1);
  pNoMatch = new Float64Array(HMAX + 1);
  pmf = new Float64Array(HMAX + 1);
  setDays(4);
  for (let i = 0; i < 10 && flashT <= 0; i++) addPerson();
}

function hit(px, py, x, y, w, h) { return px >= x && px <= x + w && py >= y && py <= y + h; }

function drawButton(ctx, x, y, label, hot) {
  ctx.fillStyle = hot ? "rgba(255,160,60,0.85)" : "rgba(0,0,0,0.65)";
  ctx.fillRect(x, y, BTN, BTN);
  ctx.strokeStyle = "rgba(255,255,255,0.45)"; ctx.lineWidth = 1;
  ctx.strokeRect(x + 0.5, y + 0.5, BTN - 1, BTN - 1);
  ctx.fillStyle = "#fff"; ctx.font = "bold 26px ui-sans-serif, system-ui";
  ctx.textAlign = "center"; ctx.textBaseline = "middle";
  ctx.fillText(label, x + BTN / 2, y + BTN / 2);
  ctx.textBaseline = "alphabetic";
}

function tick({ ctx, dt, time, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }
  const bx2 = W - PAD - BTN, bx1 = bx2 - BTN - 8, by = H - PAD - BTN;
  for (const c of input.consumeClicks()) {
    if (hit(c.x, c.y, bx1, by, BTN, BTN)) setDays(daysIdx - 1);
    else if (hit(c.x, c.y, bx2, by, BTN, BTN)) setDays(daysIdx + 1);
  }
  for (let i = 0; i < 4; i++) silentTrial();
  if (flashT > 0) { flashT -= dt; if (flashT <= 0) startTrial(); }
  else { addAcc += dt * 40; while (addAcc >= 1 && flashT <= 0) { addAcc -= 1; addPerson(); } }

  ctx.fillStyle = "#0b0e16"; ctx.fillRect(0, 0, W, H);
  const histBottom = H - PAD - BTN - 10;
  const portrait = H > W * 0.95;
  let cx, cy, Rr, hx, hy, hw, hh;
  if (portrait) {
    cx = W / 2; cy = 110 + (H * 0.52 - 110) / 2;
    Rr = Math.min(W / 2 - 26, (H * 0.52 - 110) / 2 - 8);
    hx = PAD; hw = W - 2 * PAD; hy = H * 0.56; hh = histBottom - hy;
  } else {
    cx = W * 0.25; cy = H * 0.55;
    Rr = Math.min(W * 0.24, H * 0.36) - 8;
    hx = W * 0.52; hw = W - PAD - hx; hy = 118; hh = histBottom - hy;
  }

  ctx.strokeStyle = "rgba(255,255,255,0.18)"; ctx.lineWidth = 1;
  ctx.beginPath(); ctx.arc(cx, cy, Rr, 0, TAU); ctx.stroke();
  for (let i = 0; i < nPeople; i++) {
    const d = people[i];
    const a = (d / days) * TAU - Math.PI / 2;
    const px = cx + Math.cos(a) * Rr, py = cy + Math.sin(a) * Rr;
    if (flashT > 0 && d === flashDay) {
      ctx.fillStyle = "#ff3b30";
      ctx.beginPath(); ctx.arc(px, py, 5 + 3 * Math.sin(time * 18), 0, TAU); ctx.fill();
    } else {
      ctx.fillStyle = `hsl(${195 - i * 1.4}, 90%, 62%)`;
      ctx.beginPath(); ctx.arc(px, py, 3, 0, TAU); ctx.fill();
    }
  }
  ctx.fillStyle = flashT > 0 ? "#ff6b5e" : "#cfe3ff";
  ctx.font = "bold 22px monospace"; ctx.textAlign = "center";
  ctx.fillText(String(nPeople), cx, cy + 7);
  ctx.font = "10px monospace"; ctx.fillStyle = "rgba(255,255,255,0.55)";
  ctx.fillText(flashT > 0 ? "MATCH!" : "people", cx, cy + 22);

  let ymax = 1;
  for (let n = 2; n <= NB; n++) {
    if (hist[n] > ymax) ymax = hist[n];
    const e = trials * pmf[n];
    if (e > ymax) ymax = e;
  }
  let med = 0, cum = 0;
  for (let n = 2; n <= HMAX; n++) { cum += hist[n]; if (cum * 2 >= trials) { med = n; break; } }

  ctx.fillStyle = "rgba(255,255,255,0.06)"; ctx.fillRect(hx, hy, hw, hh);
  const bw = hw / (NB - 1);
  for (let n = 2; n <= NB; n++) {
    const bh = (hist[n] / ymax) * (hh - 14);
    ctx.fillStyle = n === 23 ? "#ffb347" : n === med ? "#7fffd4" : "#3f6fd1";
    ctx.fillRect(hx + (n - 2) * bw, hy + hh - bh, Math.max(1, bw - 0.5), bh);
  }
  ctx.strokeStyle = "rgba(255,255,255,0.85)"; ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let n = 2; n <= NB; n++) {
    const py = hy + hh - ((trials * pmf[n]) / ymax) * (hh - 14);
    const px = hx + (n - 2) * bw;
    if (n === 2) ctx.moveTo(px, py); else ctx.lineTo(px, py);
  }
  ctx.stroke();
  ctx.fillStyle = "rgba(255,255,255,0.6)"; ctx.font = "10px monospace";
  ctx.textAlign = "left"; ctx.fillText("2", hx, hy + hh + 11);
  ctx.textAlign = "center"; ctx.fillText("n=23", hx + 21 * bw, hy + hh + 11);
  ctx.textAlign = "left"; ctx.fillText("group size at first shared birthday", hx + 2, hy - 4);

  ctx.fillStyle = "rgba(0,0,0,0.6)"; ctx.fillRect(PAD, PAD, 236, 86);
  ctx.fillStyle = "#fff"; ctx.font = "13px monospace"; ctx.textAlign = "left";
  ctx.fillText(`days=${days}  trials=${trials}`, PAD + 10, PAD + 20);
  ctx.fillText(`median group   = ${med}`, PAD + 10, PAD + 38);
  ctx.fillStyle = "#ffb347";
  ctx.fillText(`P(match by 23) = ${(100 * match23 / Math.max(1, trials)).toFixed(1)}%`, PAD + 10, PAD + 56);
  ctx.fillStyle = "#9fb6d4";
  ctx.fillText(`theory         = ${(100 * theory23).toFixed(1)}%`, PAD + 10, PAD + 74);

  const hotM = input.mouseDown && hit(input.mouseX, input.mouseY, bx1, by, BTN, BTN);
  const hotP = input.mouseDown && hit(input.mouseX, input.mouseY, bx2, by, BTN, BTN);
  drawButton(ctx, bx1, by, "−", hotM);
  drawButton(ctx, bx2, by, "+", hotP);
  ctx.fillStyle = "rgba(255,255,255,0.55)"; ctx.font = "11px monospace"; ctx.textAlign = "right";
  ctx.fillText("days/year", bx1 - 8, by + BTN / 2 + 4);
}

Comments (0)

Log in to comment.