Regulation boards cut from raw lumber and drawn in Fusion 360 before a single cut. Then we kept going: 269 addressable LEDs, IR hole detection, and two boards that keep score together without a wire between them.
Fifty-seven seconds, start to finish.
Nothing here was bought pre-cut. The whole board was modelled first, which meant the cut list came out of the CAD rather than out of a guess.
Modelled the whole board in Fusion 360 first — top, rails, stringers, folding legs, hole. The drawing produced a real cut list, so every piece was dimensioned before the saw came out.
The face is alternating hardwood strips, not paint or vinyl. That decision was made on paper first — the stripe layout and hole placement were sketched by hand, then built in wood.
Rails and internal stringers assembled, hole cut, and a second opening cut in the underside structure to clear the LED ring.
Sanded and finished. This is where a scratch build either looks homemade or doesn't.
ESP32, ring, buttons and IR beam wired up on a countertop and proven one subsystem at a time before anything went inside a board.
Battery holder, LED-ring mount, display bezel and button faceplates all modelled in Fusion and printed, then flush-mounted into the front rail.
Second board built and flashed, ESP-NOW sync confirmed working 5 Aug 2026.
The face isn't paint or vinyl — it's alternating hardwood. That meant the design decision had to be made before the glue-up, on paper.
Per board. All 3/4" stock unless noted. Item numbers match the Fusion drawing above.
| Item | Part | Dimensions | Notes |
|---|---|---|---|
| 1 | Board | 4' × 2' × 3/4" | The play surface. 6" hole, centre 9" from the top edge. |
| 2 | Right rail | 3'-11" × 4" × 3/4" | Long side of the frame. |
| 11 | Left rail | 3'-11" × 4" × 3/4" | Long side of the frame. |
| 10 | Top rail | 1'-9 1/2" × 4" × 3/4" | High end, behind the hole. |
| 3 | Bottom rail | 1'-9 1/2" × 4" × 3/4" | Front end. Carries the control panel. |
| 6 | Cross top | 3'-11" × 4" × 3/4" | Internal stringer. |
| 4 | Cross bottom | 3'-11" × 4" × 3/4" | Internal stringer. |
| 8 | Middle top | 1'-9 1/2" × 1'-3 117/128" × 4" | Internal divider. |
| 7 | Middle bottom | 1'-9 1/2" × 1'-3 69/128" × 4" | Internal divider. |
| 5 | Leg 1 | 1'-3 119/128" × 8 1/4" × 3/4" | Folding leg, rounded top. |
| 9 | Leg 2 | 1'-3 119/128" × 8 1/4" × 3/4" | Folding leg, rounded top. |
| 12 | Leg brace | 1'-10" × 4 1/32" × 1 61/64" | Ties the legs together. |
Hole centre 9 in from the top edge and centred side to side. Get that wrong and the board isn't regulation no matter how good the rest of it looks.
One ESP32 per board, one USB power bank, no level shifter, no separate controller. Every part below is duplicated across the two boards.
| Part | Spec | Qty | Notes |
|---|---|---|---|
| ESP32 | ESP32-WROOM-32 DevKit, 30-pin, CP2102 USB-serial | 1 | One brain per board. CP2102 matters for the driver. |
| LED ring | WESIRI 60-LED WS2812B ring | 1 | Sits under the hole. GRB colour order on this hardware. |
| LED strip | WS2812B, 60/m, IP67 waterproof | ~3.5 m | Perimeter run. Outdoor rating is not optional. |
| Display | ILI9341 2.8" SPI colour TFT, 240×320 | 1 | Driven with TFT_eSPI. Run landscape. |
| Buttons | 24 mm momentary arcade | 5 | Team1 +/−, Team2 +/−, Reset. |
| IR break-beam | 5 V NPN normally-open through-beam pair | 1 | Emitter + receiver, across the hole. |
| Power bank | 10 000 mAh USB | 1 | Use the USB-A port (5 V / 3 A), not USB-C PD. |
| Power switch | SPST round rocker | 1 | Breaks the 5 V line only. |
| USB tap | USB-A male to screw terminal | 1 | Pulls 5 V and GND off the bank. |
| Passives | 1000 µF electrolytic, 330 Ω resistor | 1 ea | Cap across the injection point, resistor in series on data. |
Half the ESP32 header is unusable, so this map is less obvious than it looks — see what went wrong.
| GPIO | Function | Notes |
|---|---|---|
| 16 | WS2812B data | Through 330 Ω. First LED is a sacrificial pixel. |
| 4 | IR break-beam signal | INPUT_PULLUP — the internal pull-up covers the NPN open-collector output. |
| 21 | I²C SDA | Display data. |
| 22 | TFT backlight | TFT_BL in the ILI9341 firmware. |
| 25 | Team 1 (Stars) + | INPUT_PULLUP, active LOW. |
| 26 | Team 1 (Stars) − | INPUT_PULLUP, active LOW. |
| 27 | Team 2 (Stripes) + | INPUT_PULLUP, active LOW. |
| 32 | Team 2 (Stripes) − | INPUT_PULLUP, active LOW. |
| 33 | Reset | INPUT_PULLUP. |
| 14 | Board identity jumper | Read once at boot. HIGH = Board A, LOW = Board B. Needs a reboot to change. |
| 17, 5 | Reserved | Left free for a future sound module. |
Identity comes from a jumper on GPIO 14, read once at boot. HIGH is Board A, LOW is Board B. That single pin drives both the OTA hostname and the ESP-NOW sender ID, so there are no per-board edited constants and no chance of two boards claiming the same name. Keep the jumper reachable — changing it needs a reboot.
One power bank per board feeds a single junction. Everything hangs off that junction in parallel — nothing daisy-chains through the ESP32.
1. LEDs take 5 V straight from the junction, never through the dev board. 2. The switch breaks the 5 V line only — ground stays continuous. 3. Everything shares a ground, or the data line has no reference and the strip misbehaves in ways that look like a dead strip.
The IR receiver is the exception: it runs off the ESP32's 3.3 V pin rather than the 5 V rail, so its output can never exceed the 3.3 V GPIO limit. The emitter runs on 5 V.
One continuous data chain, three power injection points. The controller sees a single long strip.
GPIO 16 ──[330 Ω]──> ring DIN (pixels 0–59)
│
ring DOUT
│
└──> perimeter DIN (pixels 60–268)
power injected in parallel at 3 points, all from the junction:
• ring
• perimeter start
• perimeter end
1000 µF cap across 5 V / GND at the injection point
The first physical LED is a sacrificial pixel — never used for display. It absorbs any weakness in the 3.3 V data level so a level shifter isn't needed. On this hardware the ring drove cleanly straight off the ESP32 with no shifter at all; if a longer run ever flickers, a 74AHCT125 is the fallback.
Colour order is GRB. Brightness is capped in firmware to stay inside the power bank's shared 3 A budget — 269 LEDs at full white would ask for far more than that.
Arduino IDE, FastLED and TFT_eSPI. One sketch, flashed to both boards.
ESP-NOW, on a broadcast peer — no MAC addresses hardcoded anywhere. That means an ESP32 can be swapped out without touching a line of firmware. Each packet carries the sender ID, both scores, a sequence byte and an event type.
Crucially, every packet is full state, not a delta. If one goes missing, the next event silently repairs it — there's no drift to accumulate and no resync handshake to write. The receive callback only sets flags and copies data; it never touches the LEDs or the display directly.
ESP-NOW peers must share a WiFi channel, and a board joined to an access point is locked to that AP's channel. So during play the radio runs in STA mode without connecting, with the channel pinned explicitly. OTA mode and ESP-NOW are therefore mutually exclusive: entering OTA tears down ESP-NOW, and leaving it re-initialises and re-pins the channel.
toggle OTA mode: hold Team1 + and Team1 − together
(boots OFF every time — never persisted)
Mac and board on the same 2.4 GHz SSID and the same subnet
discovery/handshake on UDP 3232
dns-sd -B _arduino._tcp # is the board advertising?
dns-sd -G v4 cornhole-board-B.local # resolve to an IP
ping cornhole-board-B.local
Arduino IDE → Tools → Port → Network Ports → cornhole-board-A
Partition scheme: Minimal SPIFFS (1.9 MB APP with OTA)
Only ever put one board in OTA mode at a time — not because it breaks, but because it's too easy to flash the wrong one. And anything that touches the radio init goes over USB: OTA can't fix a failed OTA.
The parts worth knowing before you start, rather than after.
The WS2812B data signal is ground-referenced. If the strip and the ESP32 don't share a ground, the LEDs glitch or stay dark and everything looks broken. This is the single most common wiring mistake.
269 LEDs can pull more current than the ESP32's traces and USB connector are rated for. The strip gets 5 V straight from the junction, in parallel with the ESP32 — never in series through it.
GPIO 6–11 are wired to flash. GPIO 34/35/36/39 are input-only with no internal pull-up, so they cannot run buttons. GPIO 0/2/15 are boot-strapping pins. That eliminates most of the header before you start.
Any future battery-voltage monitoring has to use an ADC1 pin (32–39), because ADC2 is unavailable while WiFi or ESP-NOW is active.
If red and green are swapped, it's GRB vs RGB, not a wiring fault. This hardware is GRB.
WiFi plus OTA pushed the binary to 104% of the default partition. "Minimal SPIFFS (1.9 MB APP with OTA)" fixes it and keeps OTA. "Huge APP" compiles but silently disables OTA.
The handshake runs on UDP 3232. A TCP port scan of 3232 will never find the board and will convince you it's offline. Use dns-sd -B _arduino._tcp instead.
Joining an access point locks the radio to that AP's channel, and ESP-NOW peers must share a channel. So OTA mode tears down ESP-NOW and restores it on exit. If sync ever "stops working", check whether a board was left in OTA mode before you suspect the sync code.
Daylight and dark.