Twelve coin flips, nine hundred times

Statistics, 0:44

A Galton board settles into a histogram that meets the Gaussian curve, with mean and deviation measured from the balls.

Read the source
/**
 * A Galton board with 12 rows and 900 balls. Each bounce is a deterministic
 * pseudorandom coin flip, so the same counts appear on every render. The readouts
 * are measured from the landed balls, and the curve is the Gaussian with
 * mean 12 * 1/2 = 6 and standard deviation sqrt(12 * 1/4) = sqrt(3). The seed is a
 * chosen one: this sample happens to land close to theory (6.00 and 1.73).
 */
import {
    Node, circlePath, curveThrough, draw, dot, dynamic, fadeIn, fadeOut, film, line, linear, morphMatching, parseColor, ramp, smooth, text, tex, write,
    type Path, type Rgba, type Ring, type Vec,
} from 'vizmatic/stage'

const ROWS = 12
const BALLS = 900
const COLUMN = 480
const CX = -240
const DX = 66, DY = 38
const TOP = -478
const FLOOR = 482
const BALL = 5.8
const PER_ROW = 5
const ROW_HEIGHT = 11

const pegX = (row: number, index: number) => CX + (index - row / 2) * DX
const pegY = (row: number) => TOP + row * DY
const binX = (bin: number) => CX + (bin - ROWS / 2) * DX

function binomial(n: number, k: number): number {
    let out = 1
    for (let i = 1; i <= k; i++) out = (out * (n - k + i)) / i
    return out
}

/** xorshift32: small, deterministic, and good enough for coin flips. */
function coin(seed: number): () => boolean {
    let state = seed >>> 0 || 1
    return () => {
        state ^= state << 13
        state ^= state >>> 17
        state ^= state << 5
        state >>>= 0
        return (state & 0x8000) !== 0
    }
}

interface Ball { flips: boolean[]; bin: number; start: number; tau: number; drop: number; land: number; slot: number }

export default film({
    title: 'The central limit theorem',
    description: 'A Galton board drops 900 balls into a histogram that settles onto the Gaussian curve.',
    fps: 60,
}, (s) => {
    const flip = coin(837343728)
    const balls: Ball[] = []
    const DEMO_END = 8.6
    let start = 0
    for (let i = 0; i < BALLS; i++) {
        const flips = Array.from({ length: ROWS }, () => flip())
        const bin = flips.filter(Boolean).length
        const demo = i === 0
        const tau = demo ? 0.4 : i === 1 ? 0.2 : 0.082
        const drop = demo ? 0.7 : 0.5
        // Spacing shrinks as the film goes on: first single balls, then a downpour.
        const progress = i / BALLS
        const spacing = i === 0 ? 6.6 : i === 1 ? 1.5 : 0.13 * (1 - smooth(Math.min(1, progress * 6))) + 0.0118
        balls.push({ flips, bin, start, tau, drop, land: start + ROWS * tau + drop, slot: 0 })
        start += spacing
    }
    const order = balls.map((_, i) => i).sort((a, b) => balls[a]!.land - balls[b]!.land)
    const counts = new Array<number>(ROWS + 1).fill(0)
    for (const index of order) {
        const ball = balls[index]!
        ball.slot = counts[ball.bin]!
        counts[ball.bin]! += 1
    }

    // Running statistics over landed balls, in landing order.
    const landings = order.map((index) => balls[index]!)
    const sum1: number[] = [0], sum2: number[] = [0]
    for (const ball of landings) {
        sum1.push(sum1[sum1.length - 1]! + ball.bin)
        sum2.push(sum2[sum2.length - 1]! + ball.bin * ball.bin)
    }
    const landedAt = (t: number): number => {
        let lo = 0, hi = landings.length
        while (lo < hi) {
            const mid = (lo + hi) >> 1
            if (landings[mid]!.land <= t) lo = mid + 1
            else hi = mid
        }
        return lo
    }

    const binColors: readonly Rgba[] = ramp(ROWS + 1, 'blue', 'teal', 'yellow', 'teal', 'blue')
    const slotPoint = (bin: number, slot: number): Vec => {
        const row = Math.floor(slot / PER_ROW), col = slot % PER_ROW
        return [binX(bin) + (col - (PER_ROW - 1) / 2) * 12.2 + (row % 2 === 0 ? -1.5 : 1.5), FLOOR - BALL - 1 - row * ROW_HEIGHT]
    }
    const piles: Ring[][] = counts.map((count, bin) => Array.from({ length: count }, (_, slot) => {
        const [x, y] = slotPoint(bin, slot)
        return circlePath(x, y, BALL)[0]!
    }))
    const pileCache = new Map<number, Path>()
    const pile = (bin: number, count: number): Path => {
        const key = bin * 10000 + count
        let path = pileCache.get(key)
        if (!path) {
            path = piles[bin]!.slice(0, count)
            pileCache.set(key, path)
        }
        return path
    }

    const position = (ball: Ball, t: number): Vec | null => {
        const local = t - ball.start
        if (local < 0) return null
        const end = ROWS * ball.tau
        if (local < end) {
            const row = Math.floor(local / ball.tau)
            const u = local / ball.tau - row
            let rights = 0
            for (let i = 0; i < row; i++) if (ball.flips[i]) rights++
            const sign = ball.flips[row] ? 1 : -1
            // Sit on top of the peg, hop off, and fall to the next row.
            const x = pegX(row, rights) + (sign * DX * 0.5) * smooth(u)
            const y = pegY(row) - 13 + DY * u * u - 15 * Math.sin(Math.PI * u) * (1 - u * 0.5)
            return [x, y]
        }
        const u = Math.min(1, (local - end) / ball.drop)
        const [tx, ty] = slotPoint(ball.bin, ball.slot)
        const rights = ball.bin
        const fromX = pegX(ROWS, rights), fromY = pegY(ROWS) - 13
        return [fromX + (tx - fromX) * u, fromY + (ty - fromY) * u * u]
    }

    const time = s.value(0)

    const board = new Node()
    for (let row = 0; row < ROWS; row++) {
        for (let i = 0; i <= row; i++) board.add(dot(4, 'dim').at(pegX(row, i), pegY(row)))
    }
    const bins = new Node()
    for (let bin = 0; bin <= ROWS + 1; bin++) {
        const x = binX(bin) - DX / 2
        bins.add(line([x, pegY(ROWS) + 6], [x, FLOOR], { stroke: 'faint', width: 1.4 }))
    }
    bins.add(line([binX(0) - DX / 2 - 12, FLOOR], [binX(ROWS) + DX / 2 + 12, FLOOR], { stroke: 'dim', width: 2 }))
    const ticks = new Node()
    for (let bin = 0; bin <= ROWS; bin++) ticks.add(tex(String(bin), { size: 21, color: 'dim', display: false }).moveTo([binX(bin), FLOOR + 26]))

    const action = dynamic(() => {
        const t = time.value
        const out: Node[] = []
        const landed = landedAt(t)
        const heights = new Array<number>(ROWS + 1).fill(0)
        for (let i = 0; i < landed; i++) heights[landings[i]!.bin]! += 1
        heights.forEach((count, bin) => {
            if (count > 0) out.push(new Node(pile(bin, count)).fill(binColors[bin]!))
        })
        for (const ball of balls) {
            if (ball.start > t) break
            if (ball.land <= t) continue
            const point = position(ball, t)
            if (!point) continue
            const color = ball === balls[0] ? parseColor('yellow') : binColors[ball.bin]!
            // A short streak behind each ball carries the speed through still frames.
            const before = position(ball, t - 0.028)
            if (before) out.push(line(before, point, { stroke: color, width: BALL * 1.5, opacity: 0.4 }))
            const mark = new Node(circlePath(point[0], point[1], BALL)).fill(color)
            if (ball === balls[0]) mark.glow(12, 'yellow')
            out.push(mark)
        }
        return out
    })

    // The theoretical curve, drawn in stack heights: one ball adds this much height.
    const heightPerBall = ROW_HEIGHT / PER_ROW
    const sigma = Math.sqrt(ROWS / 4)
    const gaussianPoints: Vec[] = []
    for (let i = 0; i <= 200; i++) {
        const k = -0.5 + (i * (ROWS + 1)) / 200
        const density = Math.exp(-((k - ROWS / 2) ** 2) / (2 * sigma * sigma)) / (sigma * Math.sqrt(2 * Math.PI))
        gaussianPoints.push([binX(k), FLOOR - BALLS * density * heightPerBall - 2])
    }
    const gaussian = new Node(curveThrough(gaussianPoints)).stroke('ink', 3.6).glow(8, 'ink')
    // Exact binomial probabilities, as a thin mark over each bin.
    const exactMarks = new Node()
    for (let bin = 0; bin <= ROWS; bin++) {
        const y = FLOOR - BALLS * (binomial(ROWS, bin) / 2 ** ROWS) * heightPerBall - 2
        exactMarks.add(line([binX(bin) - DX * 0.4, y], [binX(bin) + DX * 0.4, y], { stroke: 'pink', width: 3 }))
    }

    // ── Column ──────────────────────────────────────────────────────────────

    const rule = line([COLUMN - 70, -540], [COLUMN - 70, 540], { stroke: 'grid', width: 1.2 })
    const place = <T extends Node>(node: T, top: number): T => {
        node.alignTo([COLUMN, 0], 'left').alignTo([0, top], 'top')
        return node
    }
    const label = (content: string, top: number) => place(text(content, { size: 19, color: 'dim', uppercase: true, tracking: 0.08, weight: 600 }), top)
    const title = place(text('Twelve coin flips. Nine hundred times.', { font: 'text-serif', size: 46, maxWidth: 440, leading: 1.08 }), -490)

    const flipLabel = label('First ball, bounce by bounce', -340)
    const flipRead = dynamic(() => {
        const t = time.value
        const ball = balls[0]!
        const done = Math.max(0, Math.min(ROWS, Math.floor((t - ball.start) / ball.tau)))
        const letters = ball.flips.slice(0, done).map((right) => right ? 'R' : 'L').join(' ')
        return place(text(letters.length ? letters : '...', { font: 'mono', size: 25, color: 'yellow' }), -306)
    })
    const rightRead = dynamic(() => {
        const t = time.value
        const ball = balls[0]!
        const done = Math.max(0, Math.min(ROWS, Math.floor((t - ball.start) / ball.tau)))
        const rights = ball.flips.slice(0, done).filter(Boolean).length
        return place(text(`${rights} right, so far`, { size: 24, color: 'dim' }), -264)
    })

    const countLabel = label('Balls landed', -196)
    const countRead = dynamic(() => place(text(String(landedAt(time.value)).padStart(3, ' '), { font: 'mono', size: 64, color: 'ink' }), -170))
    const statLabelA = label('Mean', -66)
    const statReadA = dynamic(() => {
        const n = landedAt(time.value)
        return place(text(n ? (sum1[n]! / n).toFixed(2) : '-', { font: 'mono', size: 38, color: 'ink' }), -40)
    })
    const statLabelB = label('Std deviation', -66)
    statLabelB.alignTo([COLUMN + 200, 0], 'left')
    const statReadB = dynamic(() => {
        const n = landedAt(time.value)
        const variance = n ? sum2[n]! / n - (sum1[n]! / n) ** 2 : 0
        const node = place(text(n > 1 ? Math.sqrt(variance).toFixed(2) : '-', { font: 'mono', size: 38, color: 'ink' }), -40)
        node.alignTo([COLUMN + 200, 0], 'left')
        return node
    })
    const expected = place(tex('\\mu = 6, \\quad \\sigma = \\sqrt{3} \\approx 1.73', { size: 30, color: 'dim' }), 28)

    const eqExact = place(tex('P(k) = \\binom{12}{k} \\frac{1}{2^{12}}', { size: 40, color: 'ink' }), 108)
    for (const glyph of eqExact.part('P(k)')) glyph.fill('pink')
    const eqGauss = place(tex('P(k) \\approx \\frac{1}{\\sigma \\sqrt{2\\pi}} e^{-\\frac{(k-\\mu)^2}{2\\sigma^2}}', { size: 40, color: 'ink' }), 108)
    for (const glyph of eqGauss.part('P(k)')) glyph.fill('ink')
    const caption = place(text('Add up enough small independent effects, and this shape appears. No shape was put in.', { size: 24, color: 'dim', maxWidth: 400, leading: 1.42 }), 232)

    // ── Timeline ────────────────────────────────────────────────────────────

    const total = balls[BALLS - 1]!.land
    s.add(rule)
    s.chapter('The board')
    s.play(write(title), fadeIn(board, { lag: 0.008 }), { duration: 2.2 })
    s.play(draw(bins, { lag: 0.02 }), fadeIn(ticks, { lag: 0.04 }), { duration: 1.2 })
    s.add(action)
    s.play(fadeIn([flipLabel, flipRead, rightRead]), { duration: 0.6 })

    s.chapter('One ball')
    s.play(time.to(DEMO_END - 1.2, { ease: linear }), { duration: DEMO_END - 1.2 })
    s.play(fadeIn([countLabel, countRead, statLabelA, statReadA, statLabelB, statReadB]), time.to(DEMO_END, { ease: linear }), { duration: 1.2 })

    s.chapter('A downpour')
    s.play(time.to(total + 0.2, { ease: linear }), { duration: total + 0.2 - DEMO_END })
    s.wait(0.5)

    s.chapter('The exact answer')
    s.play(fadeOut([flipLabel, flipRead, rightRead], { duration: 0.6 }), fadeIn(expected), write(eqExact), fadeIn(exactMarks, { lag: 0.06 }), { duration: 1.6 })
    s.wait(1)

    s.chapter('The bell')
    s.play(draw(gaussian), morphMatching(eqExact, eqGauss), { duration: 2.4 })
    s.play(fadeIn(caption, { shift: [0, 8] }), { duration: 1 })
    s.wait(3.4)
})

Search

↑ ↓ to navigateEnter to openEsc to close
Editable TSX

Example source

Open in the builder

Open the builder to edit this scene, preview animations, and export your visual.

Example preview