Cornhole board lit at night
Build log · 2026

Two cornhole boards,
built from nothing.

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.

2boards
269addressable LEDs
12cut parts each
0kits bought
27 ftand still in sync

The short version

Fifty-seven seconds, start to finish.

First frame of the cornhole build video
Video coming soon
The full build in fifty-seven seconds.

The build

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.

24 × 48 inregulation board top
6 inhole diameter
9 inhole centre from top edge
12 inrear height (3–4 in front)
The complete board modelled in Fusion 360 — top, rails, stringers, folding legs.
The complete board modelled in Fusion 360 — top, rails, stringers, folding legs.
The drawing that came out of it. Twelve parts, every one dimensioned.
The drawing that came out of it. Twelve parts, every one dimensioned.

How it went

01

Design

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.

02

The look

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.

03

Frame

Rails and internal stringers assembled, hole cut, and a second opening cut in the underside structure to clear the LED ring.

04

Finish

Sanded and finished. This is where a scratch build either looks homemade or doesn't.

05

Bench electronics

ESP32, ring, buttons and IR beam wired up on a countertop and proven one subsystem at a time before anything went inside a board.

06

Control module

Battery holder, LED-ring mount, display bezel and button faceplates all modelled in Fusion and printed, then flush-mounted into the front rail.

07

Two boards talking

Second board built and flashed, ESP-NOW sync confirmed working 5 Aug 2026.

Raw stock. Everything started here.
Raw stock. Everything started here.
The face is alternating hardwood strips — the look lives in the layout.
The face is alternating hardwood strips — the look lives in the layout.
Frame and internal stringers going together.
Frame and internal stringers going together.
A second opening cut into the underside structure to clear the LED ring.
A second opening cut into the underside structure to clear the LED ring.
Holes cut, stripes reading properly for the first time.
Holes cut, stripes reading properly for the first time.
Finishing. This is where a scratch build either looks homemade or doesn't.
Finishing. This is where a scratch build either looks homemade or doesn't.

The look came first

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.

The original stripe layout, drawn by hand.
The original stripe layout, drawn by hand.
And the same layout in wood, with the control panel flush in the front rail.
And the same layout in wood, with the control panel flush in the front rail.

Cut list

Per board. All 3/4" stock unless noted. Item numbers match the Fusion drawing above.

ItemPartDimensionsNotes
1Board4' × 2' × 3/4"The play surface. 6" hole, centre 9" from the top edge.
2Right rail3'-11" × 4" × 3/4"Long side of the frame.
11Left rail3'-11" × 4" × 3/4"Long side of the frame.
10Top rail1'-9 1/2" × 4" × 3/4"High end, behind the hole.
3Bottom rail1'-9 1/2" × 4" × 3/4"Front end. Carries the control panel.
6Cross top3'-11" × 4" × 3/4"Internal stringer.
4Cross bottom3'-11" × 4" × 3/4"Internal stringer.
8Middle top1'-9 1/2" × 1'-3 117/128" × 4"Internal divider.
7Middle bottom1'-9 1/2" × 1'-3 69/128" × 4"Internal divider.
5Leg 11'-3 119/128" × 8 1/4" × 3/4"Folding leg, rounded top.
9Leg 21'-3 119/128" × 8 1/4" × 3/4"Folding leg, rounded top.
12Leg brace1'-10" × 4 1/32" × 1 61/64"Ties the legs together.

The one measurement that matters

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.

Electronics

One ESP32 per board, one USB power bank, no level shifter, no separate controller. Every part below is duplicated across the two boards.

PartSpecQtyNotes
ESP32ESP32-WROOM-32 DevKit, 30-pin, CP2102 USB-serial1One brain per board. CP2102 matters for the driver.
LED ringWESIRI 60-LED WS2812B ring1Sits under the hole. GRB colour order on this hardware.
LED stripWS2812B, 60/m, IP67 waterproof~3.5 mPerimeter run. Outdoor rating is not optional.
DisplayILI9341 2.8" SPI colour TFT, 240×3201Driven with TFT_eSPI. Run landscape.
Buttons24 mm momentary arcade5Team1 +/−, Team2 +/−, Reset.
IR break-beam5 V NPN normally-open through-beam pair1Emitter + receiver, across the hole.
Power bank10 000 mAh USB1Use the USB-A port (5 V / 3 A), not USB-C PD.
Power switchSPST round rocker1Breaks the 5 V line only.
USB tapUSB-A male to screw terminal1Pulls 5 V and GND off the bank.
Passives1000 µF electrolytic, 330 Ω resistor1 eaCap across the injection point, resistor in series on data.

Pin assignments

Half the ESP32 header is unusable, so this map is less obvious than it looks — see what went wrong.

GPIOFunctionNotes
16WS2812B dataThrough 330 Ω. First LED is a sacrificial pixel.
4IR break-beam signalINPUT_PULLUP — the internal pull-up covers the NPN open-collector output.
21I²C SDADisplay data.
22TFT backlightTFT_BL in the ILI9341 firmware.
25Team 1 (Stars) +INPUT_PULLUP, active LOW.
26Team 1 (Stars) −INPUT_PULLUP, active LOW.
27Team 2 (Stripes) +INPUT_PULLUP, active LOW.
32Team 2 (Stripes) −INPUT_PULLUP, active LOW.
33ResetINPUT_PULLUP.
14Board identity jumperRead once at boot. HIGH = Board A, LOW = Board B. Needs a reboot to change.
17, 5ReservedLeft free for a future sound module.

Both boards run the same binary

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.

Underside: ring, loom, control box and the drilled pass-throughs.
Underside: ring, loom, control box and the drilled pass-throughs.
The LED ring holder, modelled and printed.
The LED ring holder, modelled and printed.
Control module bracket.
Control module bracket.
Button faceplates — Power, Reset, Team 1.
Button faceplates — Power, Reset, Team 1.

Wiring

One power bank per board feeds a single junction. Everything hangs off that junction in parallel — nothing daisy-chains through the ESP32.

Power bank USB-A · 5 V / 3 A USB-A → screw terminal V+ and V− only · leave D+ / D− empty 5 V GND runs straight through Rocker switch 5 V + GND junction solder bundle or lever nut — everything meets here ESP32 VIN + GND 3.3 V out → IR receiver LEDs 5 V direct — never through the ESP32 Display VCC + GND GPIO 16 · 330 Ω Red = switched 5 V · Grey = ground (continuous) · Gold dashed = LED data

Three rules that stop most of the failures

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.

LED chain

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.

Perimeter and ring running at dusk.
Perimeter and ring running at dusk.
Hole detected. The whole board reacts.
Hole detected. The whole board reacts.

Firmware

Arduino IDE, FastLED and TFT_eSPI. One sketch, flashed to both boards.

What it does

STARS 21
STRIPES 19

How the sync works

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.

The constraint that shapes everything

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.

Flashing over the air

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 scoreboard on the bench before it went in.
The scoreboard on the bench before it went in.
And installed, at night.
And installed, at night.

What went wrong

The parts worth knowing before you start, rather than after.

Common ground or nothing works

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.

Never power LEDs through the dev board

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.

Half the GPIOs are traps

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.

ADC2 dies when the radio wakes up

Any future battery-voltage monitoring has to use an ADC1 pin (32–39), because ADC2 is unavailable while WiFi or ESP-NOW is active.

Wrong colours means wrong colour order

If red and green are swapped, it's GRB vs RGB, not a wiring fault. This hardware is GRB.

The partition scheme will bite you

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.

OTA discovery is UDP, not TCP

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.

OTA and ESP-NOW cannot coexist

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.