Final Galton board display: 16,900 balls, histogram, and timing panel
Breadboarded Pico 2 with VGA PCB, MCP4822 DAC, and rotary encoder
Measured landing distribution of 2,128,816 balls with Gaussian fit
Measured vs ideal Binomial(16, 0.5) histogram
Maximum balls at 60 fps after each optimization
O(1) nearest-peg lookup: 136 tests reduced to 1
Per-frame work split across core 0 and core 1
Precomputed hollow ball sprite, two 4-bit pixels per byte
Moving the per-ball code from flash into SRAM
Clock tree for a 350 MHz system clock, 25 MHz VGA, 50 kHz audio
System-level block diagram
fix15 and packed ball struct formats
First VGA demo: two bouncing balls

Project information

  • Course: ECE 5730
  • Date: October 2026
  • Team: Amber Li, Ivan Huang, Zarif Karim
  • Platform: Raspberry Pi Pico 2 (RP2350)
  • Peripherals: VGA (custom adapter PCB), MCP4822 SPI DAC, rotary encoder with push button
  • Result: 16,900 balls at 60 fps, 150 MHz → 350 MHz
  • Report: Lab Report (PDF)
Embedded C RP2350 VGA / PIO DMA Fixed-Point Math Dual Core Overclocking Collision Physics

Summary

For Cornell's ECE 5730, my team built a digital Galton board on the Raspberry Pi Pico 2 (RP2350). A user-set number of balls drop through 16 rows of 136 pegs on a 640×480 VGA display, bouncing with real collision physics until they land in 17 bins and form a bell-curve histogram. Every peg strike plays a short "thunk" through an SPI DAC, driven entirely by DMA, and a rotary encoder changes the ball count, bounciness, and gravity.

The first measured version handled 5,000 balls at the default 150 MHz clock. After a series of optimizations the final system runs 16,900 balls at 60 fps, with a 350 MHz overclock and both cores working on every frame.

Physics

  • Each frame a ball moves by its velocity, then gravity is added to its vertical velocity. Fast balls are split into substeps of about 4 px so they cannot skip over pegs
  • Pegs are stationary balls of infinite mass. On contact the velocity is reflected about the unit normal, v′ = v − 2(v·n̂)n̂, and only approaching balls reflect
  • Energy loss (the bounciness factor) and the sound are applied only when a ball hits a new peg, so a ball rolling along one peg does not keep slowing down or retrigger the sound
  • All physics uses fix15, a signed 17.15 fixed-point format. Spawn velocity is forced odd so a ball can never sit at vx = 0 on the top peg

Controls: Rotary Encoder

The encoder's A and B channels are decoded in a GPIO interrupt that fires on both edges of both pins. The previous and current 2-bit states index a 16-entry Gray-code lookup table that adds +1, −1, or 0, and a click only counts when the knob returns to rest after four steps in one direction, so contact bounce cancels itself out. 0.1 µF capacitors on each channel add hardware debouncing. The push button is polled once per frame and cycles the knob between ball count (±100), bounciness (±0.5), and gravity (±0.05). Changing any parameter resets the histogram.

DMA Audio

At startup an 800 Hz sine "thunk" with a short attack and decay is generated once, with the MCP4822 configuration bits already attached to each sample. Two chained DMA channels stream it to the SPI DAC, one rewinding the read pointer and one moving the samples, paced by a DMA timer at 50 kHz. The CPU only starts the control channel when a ball hits a peg, and skips the sound if one is already playing, so there are no audio interrupts at all.

VGA Display

Built on the course's double-buffered VGA driver: three PIO state machines produce hsync, vsync, and 4-bit color while a DMA chain feeds pixels. A frame is 640×480×4 bits, two pixels per byte, which matters later for the ball sprite and leaves RAM as a real limit. We soldered a custom adapter PCB for the VGA connections. An on-board LED lights whenever a frame misses the 60 fps deadline.

Optimizations

  • O(1) peg lookup: only the nearest peg can touch a ball, so its row and column are computed directly from the ball's position. 136 collision tests per substep become one
  • Dual-core split: core 0 owns balls [0, split) and core 1 owns [split, N), with no locks inside the frame. Core 0 adjusts the split by 0.1% per frame toward whichever core finished first, with a 50 µs deadband, and each core keeps its own histogram counters. Four semaphores give each frame a fork–barrier–join structure
  • Parallel screen clear: each core clears half of the 153,600-byte frame buffer
  • Precomputed hollow ball sprite: the row widths are computed once and whole bytes (two pixels) are written straight into the frame buffer instead of calling fillCircle. This reached 5,000 balls at 150 MHz; cheaper collision math (squared-distance test, one 1/sqrt per real hit, one velocity divide per frame) brought it to 5,200
  • Overclock 150 → 350 MHz: core voltage raised to 1.30 V, the system PLL reprogrammed (1050 MHz VCO, ÷3 ÷1), and the VGA PIO clock dividers and pixel hold cycles changed to keep a 25 MHz pixel clock
  • Ball struct 24 → 12 bytes: RAM became the limit after overclocking. Flags were packed into the low 5 bits of the position words and velocity stored as fix10. An earlier 8-fractional-bit velocity gave only 128 distinct paths and broke the histogram, so it was reverted
  • Hot code in SRAM: both cores were fighting over the shared 16 KB flash cache, and GCC had turned the sprite's byte loop into memset calls into flash. Moving the per-ball path to SRAM and writing the bytes directly took the limit from 13,000 balls at 0 µs spare to 16,000 balls with about 600 µs spare

Results

Running at 350 MHz with 16,900 balls on screen, we dropped 2,128,816 balls over 669 s. The measured bin distribution has a mean of 8.00 and a standard deviation of 2.05 bins, against an ideal Binomial(16, 1/2) with μ = 8 and σ = 2, and the two histograms are nearly identical.

The physical board breaks the strict i.i.d. assumptions of the central limit theorem, since the only randomness is the spawn and the later motion is deterministic and correlated. It still produces a bell curve because the dynamics amplify tiny differences and forget earlier rows within a few pegs, so the 16 rows act like nearly independent left/right choices. The curve stops looking Gaussian when bounciness is high enough that balls skip pegs.

The report appendix also includes the commented source and a log of the AI-assisted portions of the code.