The Galton Board
Watch the Central Limit Theorem in action as individual particles bounce through the peg board to form a Normal Distribution.
viewof _num_rows = Inputs.range([5, 30], {step: 1, label: "Peg Rows", value: 15})
viewof _prob_right = Inputs.range([0.1, 0.9], {step: 0.05, label: "Right Prob (p)", value: 0.5})
viewof drop_rate = Inputs.range([1, 20], {step: 1, label: "Drop Speed", value: 8})
// Simple promise-based debounce
num_rows = {
const val = _num_rows;
return new Promise(resolve => setTimeout(() => resolve(val), 250));
}
prob_right = {
const val = _prob_right;
return new Promise(resolve => setTimeout(() => resolve(val), 250));
}
// Controls
viewof run_trigger = Inputs.button("Drop another 100 balls")
viewof clear_trigger = Inputs.button("Clear Histogram")
This simulator runs entirely in your browser using Observable JS. No server-side processing or external workers are required.
function generate_paths(n_balls, n_rows, p) {
const paths = [];
for (let i = 0; i < n_balls; i++) {
let curr_x = 0;
const path = [0];
for (let j = 0; j < n_rows; j++) {
if (Math.random() < p) {
curr_x += 1;
}
path.push(curr_x);
}
paths.push(path);
}
return paths;
}
// Initial drop on load + button clicks
all_paths = {
run_trigger; // force re-evaluation
return generate_paths(100, num_rows, prob_right);
}
mutable active_balls = []
mutable bin_counts = new Array(num_rows + 1).fill(0)
// Helper to spawn balls into the active_balls list
spawn_balls = {
const paths = all_paths;
const start_paths = paths.map(p => ({
path: p,
row: 0,
progress: 0,
x: 0,
y: 0,
id: Math.random()
}));
start_paths.forEach((b, i) => {
setTimeout(() => {
mutable active_balls = [...mutable active_balls, b];
}, i * (500 / drop_rate));
});
}
// Reset if parameters change OR clear is clicked
reset_bins = {
num_rows, prob_right, clear_trigger;
mutable bin_counts = new Array(num_rows + 1).fill(0);
mutable active_balls = [];
}
height = 500
width = 600
peg_r = 3
ball_r = 5
v_spacing = (height - 120) / (num_rows + 2)
h_spacing = Math.min(width / (num_rows + 2), 40)
canvas = {
const ctx = DOM.context2d(width, height);
let frame;
// Mathematical Curve (Normal Approximation)
const mu = num_rows * prob_right;
const sigma = Math.sqrt(num_rows * prob_right * (1 - prob_right));
const max_pdf = 1 / (sigma * Math.sqrt(2 * Math.PI));
function draw() {
ctx.clearRect(0, 0, width, height);
const centerX = width / 2;
const startY = 50;
// 1. Draw Bins (Bars)
const max_count = Math.max(...mutable bin_counts, 10);
const binY = height - 20;
const barHeightMax = 120;
for (let i = 0; i <= num_rows; i++) {
const x = centerX + (i - num_rows/2) * h_spacing;
const barH = (mutable bin_counts[i] / max_count) * barHeightMax;
// Rainbow bins!
ctx.fillStyle = `hsl(${(i / num_rows) * 280}, 60%, 65%)`;
ctx.fillRect(x - h_spacing/2 + 2, binY - barH, h_spacing - 4, barH);
if (mutable bin_counts[i] > 0 && h_spacing > 12) {
ctx.fillStyle = "#2c3e50";
ctx.font = "bold 9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(mutable bin_counts[i], x, binY - barH - 5);
}
}
// 2. Draw Theoretical Normal Curve
if (sigma > 0) {
ctx.beginPath();
ctx.strokeStyle = "#95a5a688"; // Subtle neutral curve
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
for (let i = 0; i <= num_rows * 10; i++) {
const x_val = i / 10;
const x_coord = centerX + (x_val - num_rows/2) * h_spacing;
// Normal PDF
const pdf = (1 / (sigma * Math.sqrt(2 * Math.PI))) *
Math.exp(-0.5 * Math.pow((x_val - mu) / sigma, 2));
// Scale PDF to match the bars.
// We scale such that the area matches total balls, but for visualization
// we scale relative to the max_pdf vs barHeightMax.
const y_coord = binY - (pdf / max_pdf) * barHeightMax;
if (i === 0) ctx.moveTo(x_coord, y_coord);
else ctx.lineTo(x_coord, y_coord);
}
ctx.stroke();
ctx.setLineDash([]);
}
// 3. Draw Pegs
ctx.fillStyle = "#bdc3c7";
for (let r = 0; r <= num_rows; r++) {
for (let k = 0; k <= r; k++) {
const x = centerX + (k - r/2) * h_spacing;
const y = startY + r * v_spacing;
ctx.beginPath();
ctx.arc(x, y, peg_r, 0, Math.PI * 2);
ctx.fill();
}
}
// 4. Update and Draw Balls
const speed = 0.05 * (drop_rate / 8);
const still_active = [];
mutable active_balls.forEach(b => {
b.progress += speed;
if (b.progress >= 1) {
b.row += 1;
b.progress = 0;
}
// Handle the bottom or row change mismatch
if (b.row >= num_rows || b.path[b.row + 1] === undefined) {
const final_bin = b.path[Math.min(b.row, b.path.length-1)];
if (final_bin !== undefined) mutable bin_counts[final_bin]++;
return;
}
const r = b.row;
const k1 = b.path[r];
const k2 = b.path[r+1];
const x1 = centerX + (k1 - r/2) * h_spacing;
const y1 = startY + r * v_spacing;
const x2 = centerX + (k2 - (r+1)/2) * h_spacing;
const y2 = startY + (r+1) * v_spacing;
b.x = x1 + (x2 - x1) * b.progress;
b.y = y1 + (y2 - y1) * b.progress;
b.y -= Math.sin(b.progress * Math.PI) * (v_spacing / 2.5);
// Rainbow balls!
const current_r = b.row + b.progress;
const current_k = k1 + (k2 - k1) * b.progress;
const ratio = current_r <= 0 ? 0.5 : current_k / current_r;
ctx.fillStyle = `hsl(${ratio * 280}, 80%, 50%)`;
ctx.beginPath();
ctx.arc(b.x, b.y, ball_r, 0, Math.PI * 2);
ctx.fill();
still_active.push(b);
});
mutable active_balls = still_active;
frame = requestAnimationFrame(draw);
}
draw();
invalidation.then(() => cancelAnimationFrame(frame));
return ctx.canvas;
}
The Mathematics
The Galton Board is a physical model of the Binomial Distribution. For each row \(i\), the ball makes a choice: Left or Right.
If there are \(n\) rows, the number of right turns \(X\) follows: \[X \sim \text{Binomial}(n, p)\]
As the number of rows \(n\) and balls increases, this distribution is approximated by the Normal Distribution: \[X \approx \mathcal{N}(np, np(1-p))\]
This is a fundamental result in statistics known as the De Moivre–Laplace theorem, a special case of the Central Limit Theorem.