- Go 100%
| assets | ||
| memory | ||
| stage1 | ||
| stage2 | ||
| stage3 | ||
| stage4 | ||
| go.mod | ||
| go.sum | ||
| input_button.go | ||
| main.go | ||
| README.md | ||
| xiao.go | ||
Build Your Own LED Racing Game in a Weekend
A strip of 300 tiny lights. Two buttons. Zero to race track in an afternoon.
Have you ever looked at an arcade machine and thought "I could build something like that"? This weekend, you will.
We're going to turn a strip of programmable LEDs into a racing track. Each player gets a button. You smash it as fast as you can. The faster you click, the faster your colored dot flies down the strip. First one to lap the track three times wins.
The whole thing runs on a microcontroller the size of your thumb, programmed in Go — and by the end of this project, you'll understand exactly how it works.
Before we start: what even is a microcontroller?
Your laptop runs thousands of programs at the same time: a browser, a music player, notifications, background updates. It has gigabytes of memory and a CPU that would baffle a scientist from the 1990s.
A microcontroller is the opposite of that. It does one thing, and it does it extremely well. No operating system, no multitasking, no distractions. You write a program, you flash it onto the chip, and that program runs — directly on the hardware — from the moment you plug it in until the moment you unplug it.
That directness is what makes microcontrollers magic for projects like this. When you press a button, your code feels it. When you tell an LED to turn red, it turns red — no layers in between, no waiting. You are talking directly to the machine.
What are we building?
┌──────────────────────────────────────────────────────────┐
│ ● ● ● ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ○ ● ○ ○ ○ ○ ○ ○ ○ ○ ○ ● │
│ R GREEN PLAYER BLUE P. │
└──────────────────────────────────────────────────────────┘
↑ LED strip (300 LEDs = the race track)
[RED BTN] [GREEN BTN] [YELLOW BTN] [BLUE BTN]
- A WS2812B LED strip is the race track — 300 individually controllable colored lights in a row.
- Each colored dot on the strip is a player's car.
- Each player has a button. Click it to accelerate. Stop clicking and friction slows you down.
- There are ramps — sections where gravity pulls you back (uphill) or launches you forward (downhill).
- A buzzer counts down at the start and plays a victory melody when someone wins.
- After 30 seconds of nobody playing, the strip explodes into a rainbow demo animation.
Up to four players can race at once. You can also replace the buttons with any sensor you like — a distance sensor, a pressure pad, even an accelerometer.
The technology stack
Hardware: Seeed Studio XIAO ESP32C3
This is our microcontroller. It's tiny (21 × 17 mm — smaller than a coin), cheap (around €5), and packs a full 32-bit processor, 400 KB of RAM, and built-in WiFi. We're not using the WiFi today, but it means this same board can be extended into a networked leaderboard later.
Language: TinyGo
TinyGo is Go — the same language used by companies like Google, Cloudflare, and Docker — compiled for tiny devices. You get the clean syntax of a modern language with code that runs on a chip that costs less than a coffee.
If you've never written Go before, don't worry. We'll walk through every line of code that matters. The language is designed to be readable.
What you need
Parts
| Qty | Component | Why we need it | ~Price |
|---|---|---|---|
| 1 | Seeed Studio XIAO ESP32C3 | The brain of the project | €5 |
| 1 | WS2812B LED strip, 5m / 300 LEDs | The race track | €20 |
| 1–4 | Push buttons (momentary) | Player controllers | €1–3 each |
| 1 | 5V power supply (≥5A) | Powers the LED strip — a phone charger is not enough | €10 |
| 1 | USB-C cable | Connects XIAO to your computer | — |
| 1 | 330Ω resistor | Protects the first LED on the strip | €0.10 |
| — | Jumper wires | Connects everything | €3 |
| 1 | Breadboard | Holds it all together without soldering | €3 |
Total for a 2-player setup: ~€25–30
Tip about buttons: Regular push buttons from an electronics shop work perfectly. But if you want the full arcade experience, look for 24mm LED arcade buttons on AliExpress (search "24mm arcade button"). They have a light built in, they're incredibly satisfying to click, and they look professional. They cost about €2 each.
Software
- TinyGo — the compiler
- Go 1.22 or newer — TinyGo builds on top of it
- A terminal (Command Prompt, Terminal, or any shell)
- A text editor (VS Code works great)
Setting up TinyGo
If you've never installed a compiler before, this might feel intimidating. Take it one step at a time.
Step 1 — Install Go
Download the installer from go.dev/dl and run it. Open a terminal and check it worked:
go version
You should see something like go version go1.22.0. If you do, Go is ready.
Step 2 — Install TinyGo
Follow the guide for your operating system at tinygo.org/getting-started/install.
Check it worked:
tinygo version
Step 3 — Get this project
git clone https://code.madriguera.me/GoEducation/ledrace
cd ledrace
go mod tidy
go mod tidy downloads the LED strip and buzzer drivers that our code needs.
It only needs to run once.
Step 4 — Connect your XIAO ESP32C3
Plug it into your computer with a USB-C cable.
First time only: Your computer needs a driver to recognise the board. On most systems it installs automatically. If TinyGo can't find the board, hold the BOOT button on the XIAO while plugging in the USB cable — this puts the chip into bootloader mode so it can be programmed.
The stages
We're going to build this in four stages, from a simple LED animation to a full racing game. You can stop at any stage and have a working, flashable project — each stage is complete on its own.
Flash any stage with:
tinygo flash -target xiao-esp32c3 ./stage1
(Replace stage1 with stage2, stage3, or stage4 as you progress.)
Stage 1 — Hello, LED Strip!
Time: 20–30 minutes
What you'll learn: How WS2812 LEDs work, how to talk to hardware from Go
What it looks like when it works: The whole strip lights up red, then cycles
through green and blue, then a colored dot chases around the track.
Wiring
Start simple. You only need three connections:
XIAO ESP32C3 WS2812B LED strip
───────────── ─────────────────
D10 (GPIO10) ──[330Ω]──→ DIN (data in)
GND ──────────→ GND
(not connected) 5V ← connect to your 5V power supply directly
Important: Do NOT connect the LED strip's 5V to the XIAO's pins. 300 LEDs can draw up to 18 A at full white brightness — a phone charger can't handle that. Use a dedicated 5V 5A switching power supply (search "5V 5A LED power supply"). Power the strip directly from it and connect only the GND wire to the XIAO so they share a common reference. This is called a shared ground, and it's essential for signals to work correctly between circuits powered by different sources.
The 330Ω resistor between D10 and the data line is a small but important detail. It dampens electrical reflections on the wire that can cause the first LED to flicker or display the wrong color. It costs a fraction of a cent and it's worth adding.
The code
Open stage1/main.go. Let's look at the important parts:
const TRACK_LEN = 300
This is the number of LEDs in your strip. If you use a different length, change this constant and the code adapts automatically.
leds := make([]color.RGBA, TRACK_LEN)
This creates a slice — Go's name for a resizable list — with one color value for each LED. Think of it as a paint palette: we fill in the colors we want, then send the whole thing to the strip at once.
leds[0] = color.RGBA{R: 50, A: 255}
RGBA stands for Red, Green, Blue, Alpha. We set Red to 50 (out of 255) and
Alpha to 255. The WS2812 driver uses the Alpha channel as a brightness switch
— if Alpha is 0, the LED stays off regardless of the other values. Always set
A: 255.
strip.WriteColors(leds)
This is the line that actually sends the colors to the strip. Until you call
this, nothing changes. The LED strip communicates over a protocol called
WS2812 (also known as NeoPixel): data is sent as a timed sequence of high and
low pulses on a single wire. The ws2812 driver handles all of that timing
for you.
Flash it
tinygo flash -target xiao-esp32c3 ./stage1
You should see:
- The whole strip lights up dim red for 2 seconds
- Then green for 1 second, then blue for 1 second
- Then a single colored dot chases around the track, cycling through the four player colors each lap
If the strip flickers or shows random colors, check the GND connection and the 330Ω resistor.
Stage 2 — Your First Racer
Time: 20–30 minutes
What you'll learn: Reading a button, the game loop, basic physics simulation
What it looks like: A red dot zooms down the strip when you click the button,
slows down if you stop, and blinks the whole strip red when you finish 3 laps.
New wiring
Add one button between D5 and GND:
XIAO ESP32C3 Component
───────────── ─────────
D10 (GPIO10) ──[330Ω]──→ WS2812 DIN (same as stage 1)
D5 (GPIO7) ──────────→ Button ──→ GND
The button connects D5 to GND when you press it. The XIAO has internal pull-up resistors — tiny resistors built into the chip that hold the pin at 3.3V when nothing is connected. When you press the button, it pulls D5 to 0V. The chip detects the difference between 3.3V and 0V as a "not pressed" vs "pressed" signal. No external resistors needed.
Understanding the game loop
Every game — from Minecraft to this racing game — has the same basic structure called the game loop:
while the game is running:
1. Read input (is the button pressed?)
2. Update the world (move the car, apply friction)
3. Draw the world (light up the LEDs)
4. Wait a short time
repeat
Our loop runs 100 times per second (we sleep 10 milliseconds between each iteration). That's our game's "frame rate."
Understanding the physics
The car's movement is driven by two values: speed and position.
const ACCELERATION = 0.30 // speed added per click
const FRICTION = 0.018 // fraction of speed lost each tick
Each click adds 0.30 to the speed. Each tick, the speed loses 1.8% of its current value. This means:
- One click → speed jumps to 0.30, then gradually fades to zero
- Many fast clicks → speed builds up to 2, 3, 4... but friction increases as speed increases, so there's a natural top speed
This is a simple but surprisingly realistic model — it's how racing games have simulated cars since the 1970s.
players[i].position += players[i].speed
Position increases every tick by the current speed. The LED we light up is
position % TRACK_LEN — the remainder when dividing position by the track
length. This makes the car loop around the track automatically.
The click detection trick
Look at this in the code:
isPressed := !btn.Get()
if wasPressed && !isPressed {
speed += ACCELERATION
}
wasPressed = isPressed
We don't trigger on the press — we trigger on the release. When wasPressed
was true and now isPressed is false, that's when a click happened. This
gives a natural "click" feel and prevents holding the button down to cheat.
Flash it
tinygo flash -target xiao-esp32c3 ./stage2
Try it. Notice how the car immediately starts losing speed if you stop clicking. The faster you click, the faster it goes — but there's a speed limit imposed by friction.
Stage 3 — Race Day
Time: 20–30 minutes
What you'll learn: Structs, multiple players, first-to-finish logic
What it looks like: Two colored dots race each other. First to 3 laps wins
and the strip flashes their color.
New wiring
Add a second button on D8:
XIAO ESP32C3 Component
───────────── ─────────
D10 (GPIO10) ──[330Ω]──→ WS2812 DIN
D5 (GPIO7) ──────────→ Player 1 button (RED) ──→ GND
D8 (GPIO8) ──────────→ Player 2 button (GREEN) ──→ GND
What's new in the code: structs
In stage 2, we had separate variables for the single player's speed, position,
button, and color. With two players, we'd need player1Speed, player2Speed,
player1Position... that gets messy fast.
A struct solves this. It's a way to bundle related data together:
type Player struct {
button machine.Pin
color color.RGBA
speed float32
position float32
laps uint8
wasPressed bool
}
Now we can have players[0] and players[1], and each has their own speed,
position, and button — all neatly packaged. We can also loop over all players
with a single for loop, which is why adding a third or fourth player later
only requires adding a few lines.
Flash it
tinygo flash -target xiao-esp32c3 ./stage3
This is already a fully playable two-player game. Find a friend, pick your buttons, and race.
Stage 4 — The Full Experience
Time: 1–2 hours
What you'll learn: Ramp physics, buzzer melodies, demo mode, interfaces,
build tags
What it looks like: The complete game — 4 players, ramps that launch or
slow your car, a countdown melody at the start, a victory jingle, and a
rainbow light show when nobody's playing.
Why we use transistors
The XIAO ESP32C3 runs at 3.3 V and each GPIO pin can safely deliver a maximum of 12 mA. An arcade button LED or a passive buzzer needs more than that.
Connecting them directly to the pin would damage the chip over time.
The solution is a PN2222A NPN transistor acting as a low-side switch. The XIAO pin controls the base (just a few milliamps through a 1 kΩ resistor), while the actual load current flows from the 5 V supply through the collector:
5V ──── LED (+) ──── LED (−) ──[220Ω]── Collector (C)
PN2222A
[1kΩ] ───── Base (B) ←── XIAO pin
Emitter (E) ──── GND
- XIAO pin HIGH → transistor ON → current flows → LED on
- XIAO pin LOW → transistor OFF → no current → LED off
The same circuit applies to the buzzer, replacing the LED with the buzzer coil.
Full wiring
XIAO D10 ──[330Ω]──────────────────────────────── WS2812 DIN
XIAO D0 ──[1kΩ]──→ PN2222A base Collector ←── 5V via buzzer
Emitter ──── GND
XIAO D5 ──────────────────────────────────────── P1 button ── GND
XIAO D6 ──[1kΩ]──→ PN2222A base Collector ←── 5V via P1 LED + 220Ω
Emitter ──── GND
XIAO D8 ──────────────────────────────────────── P2 button ── GND
XIAO D7 ──[1kΩ]──→ PN2222A base Collector ←── 5V via P2 LED + 220Ω
Emitter ──── GND
XIAO D1 ──────────────────────────────────────── P3 button ── GND
XIAO D2 ──[1kΩ]──→ PN2222A base Collector ←── 5V via P3 LED + 220Ω
Emitter ──── GND
XIAO D3 ──────────────────────────────────────── P4 button ── GND
XIAO D4 ──[1kΩ]──→ PN2222A base Collector ←── 5V via P4 LED + 220Ω
Emitter ──── GND
XIAO D9 — free
The player LEDs are the small indicator lights on each controller that flash when you click. They're optional — if you skip them, everything still works.
Strapping pin D8 (GPIO8): The ESP32C3 reads this at boot to control serial output from the ROM. Using it as a button input with internal pull-up is safe — the pull-up keeps it HIGH, which is the correct default. D9 is left free precisely to avoid boot-mode conflicts with transistor circuits.
How ramps work
The original Open LED Race project has a brilliant mechanic: sections of the track have gravity. An uphill ramp slows your car; the downhill side launches it. This creates tactical moments — do you enter the ramp fast enough to make it to the top?
In the code, each of the 300 LEDs has a gravity value:
gravity[i] == 127 → flat ground (no effect)
gravity[i] < 127 → uphill (slows you down)
gravity[i] > 127 → downhill (speeds you up)
Every tick, we check the gravity at the car's current position:
cell := track.gravity[uint16(p.position)%TRACK_LEN]
if cell < 127 {
p.speed -= GRAVITY * float32(127-cell) // uphill: lose speed
} else if cell > 127 {
p.speed += GRAVITY * float32(cell-127) // downhill: gain speed
}
```s you want and tune their steepness by
changing the `height` parameter.
### The gravity preview
Hold Player 1's button while powering up the board and the strip will show
the ramp positions: red LEDs are uphill sections, green LEDs are downhill.
Let go of the button to start the race.
### Demo mode
If nobody plays for 30 seconds, the game enters demo mode: a smooth rainbow
animation dances across the entire strip. Press any button to jump back into
a new race.
This is a common pattern in arcade games — it keeps the display alive and
attractive when nobody's playing.
### Swapping the controllers: interfaces and build tags
One of the most interesting parts of stage 4 is how we handle alternative
input devices. The code defines a `PlayerInput` *interface*:
```go
type PlayerInput interface {
Clicked() bool
}
An interface is a contract: any type that has a Clicked() method that
returns bool satisfies this interface. The rest of the game doesn't care
how the input works — just that it can answer "was there a click?".
This means we can swap the push button for an ultrasonic distance sensor without touching a single line of game logic:
// Flash with buttons (default)
tinygo flash -target xiao-esp32c3 ./stage4
// Flash with HC-SR04 distance sensor
tinygo flash -target xiao-esp32c3 -tags ultrasonic ./stage4
The -tags ultrasonic flag tells the compiler to include
input_ultrasonic.go instead of input_button.go. This is called a
build tag — a way to select which files get compiled depending on your
target.
Flash it
tinygo flash -target xiao-esp32c3 ./stage4
What's actually happening when you flash?
When you run tinygo flash:
- TinyGo compiles your Go source code into machine code — raw binary instructions for the ESP32C3's RISC-V processor.
- The binary is sent over USB to the XIAO's bootloader.
- The bootloader writes it to the chip's flash memory.
- The chip resets and your program starts running immediately.
The whole binary for this game is a few dozen kilobytes. Your phone has applications that are a thousand times larger.
Alternative controllers
One of the most creative parts of this project is the controller. Anything that can signal "now!" can be a player input. Here are some ideas:
Ultrasonic distance sensor (HC-SR04)
Wave your hand over the sensor — when your hand enters the zone and leaves, it counts as a click. See stage4/input_ultrasonic.go for the full implementation.
Capacitive touch pad
A piece of aluminium foil connected to a pin (through a 1MΩ resistor) becomes a touch sensor. Touch it = click.
Piezo knock sensor
Tape a piezo buzzer to the table. The analog signal spikes every time you tap the surface. Tap the table to accelerate.
IR proximity sensor (TCRT5000)
Pass your hand in front of the sensor to break the infrared beam. Great for gesture-style play.
Hand grip pressure sensor (FSR)
A Force Sensitive Resistor changes resistance based on how hard you squeeze. Connect it to an analog pin. Squeeze harder = accelerate faster. Attach it to a gym grip for full immersion.
Flex sensor
Bend your finger while wearing a glove with a flex sensor sewn in. Straighten = click. Great for wearable builds.
Troubleshooting
The strip doesn't light up at all
- Check the DIN (data) connection and the 330Ω resistor
- Make sure GND is shared between the XIAO and the strip
- Verify the strip is powered (5V power supply connected)
Some LEDs show the wrong color
- Cheap LED strips sometimes have the R and G channels swapped. Try changing
color.RGBA{R: 255}tocolor.RGBA{G: 255}and see if what you expect to be red turns green instead. If so, addws2812.GRBAmode to your driver init:ws2812.NewWS2812GRB(pin)
TinyGo can't find the board
- Hold the BOOT button on the XIAO while connecting the USB cable to enter bootloader mode
- On Linux, you may need to add a udev rule for the USB device
The button doesn't respond
- Make sure the button connects the correct pin directly to GND (D5 for P1, D8 for P2)
- Try
!btn.Get()instead ofbtn.Get()if the logic seems inverted
No sound from the buzzer
- Check you're using a passive buzzer (no built-in oscillator)
- Confirm D0/A0 is connected to the positive leg (+) of the buzzer
Going further
You've built a working arcade game from scratch. Here are some directions you can take it next:
WiFi scoreboard: The ESP32C3 has built-in WiFi. Add a web interface that shows live lap times and race results on any phone that connects to the board's network.
Power-ups: Add special zones on the track — LEDs in a distinct color — that trigger a speed boost, enemy freeze, or reversed controls when your car hits them.
Custom track layouts: Add more ramps, reverse sections, or a "pit stop" zone where cars must slow down before they can reaccelerate.
Wireless controllers: Replace the wired buttons with RF modules (like nRF24L01) so each player has a handheld remote.
Persistent leaderboard: Use the ESP32C3's flash storage to save the top 3 lap times across reboots.
License
MIT — Copyright 2019–2026 Daniel Esteban (conejo@conejo.me)
Based on the original Open LED Race by @openledrace. Video by @PintusMauro.
