I Built a Live Pickleball Departure Board with a $20 Raspberry Pi
I have a problem.
I built an API for professional pickleball data, which means I now have a perfectly reasonable excuse to connect increasingly unreasonable things to it.
A few weeks ago I wrote about some of the things you could build with Pickleball API, including this throwaway suggestion:
Maybe it’s a completely unnecessary Raspberry Pi scoreboard sitting on your desk.
So obviously I had to build one.
But I didn’t want another Raspberry Pi project that was really just a website running fullscreen on a tiny HDMI monitor.
I wanted pixels.
Specifically, I wanted something that looked like one of those amber dot-matrix departure boards you see at train stations.
So this is what we’re building:
Pickleball API → Wi-Fi → Raspberry Pi Zero 2 W → 128×32 LED matrix → live pickleball scores
No desktop environment.
No browser.
No HDMI display.
Just a tiny Linux computer quietly fetching sports data and turning it into glowing orange dots.
What we’re going to build
The finished display uses two 64×32 RGB LED matrix panels mounted side-by-side.
That gives us:
128 × 32 pixels
or 4,096 pixels in total.
Which isn’t much if you’re building a monitor.
For a fake train departure board, it feels luxurious.
The finished display looks something like:
● LIVE JOHNS 11
COLLIN 8
PICKLEBALL OPEN · QF
Behind it is this pipeline:
Pickleball API
↓
fetch live match
↓
normalize data
↓
choose layout
↓
render pixels
↓
HUB75 matrix
We’re taking structured internet data and pushing it all the way through to physical hardware.
Which is much more fun than another dashboard.
I've put all the code from this project into a GitHub repo so you don't have to copy everything out of the article by hand. It includes the display code, API fetching, normalisation layer, systemd service file, and setup notes, so you can clone the project onto your Pi and follow along from there. I'll also use the repo for fixes and improvements as I keep developing the scoreboard, so if something in this post changes later, that's the best place to check first.
What you’ll need
Here is the exact hardware I’d use for the US version of this build.
Prices checked July 2026. Treat them as approximate: tax, shipping and availability will vary.
Part Current price
Raspberry Pi Zero 2 W with pre-soldered header $20.75
Adafruit RGB Matrix Bonnet $14.95
2 × Waveshare 64×32 P2.5 HUB75 panels $35.98
Official Raspberry Pi 32GB microSD $13.69
5V 10A supply for LED panels $29.95
Official 5.1V 2.5A Pi power supply $8.80
Total before enclosure/tax/shipping $124.12
Raspberry Pi Zero 2 W with header — $20.75
I’m specifically using the pre-soldered-header version.
You can save a few dollars by buying the bare board and soldering the 40-pin header yourself, but I’d rather keep this project accessible to someone who hasn’t soldered before.
PiShop US currently lists the headered Zero 2 W at $20.75. Availability comes and goes.
Adafruit RGB Matrix Bonnet — $14.95
This plugs directly into the Pi’s GPIO header and gives us a much cleaner way to drive HUB75 LED panels, including the required level shifting.
2 × Waveshare P2.5 64×32 RGB matrices — $17.99 each
Each panel is:
64 × 32 pixels
160 × 80 mm
2.5 mm pixel pitch
HUB75
5V
Two mounted horizontally give us a 320 × 80mm display area at 128×32 pixels.
Waveshare currently lists the standard panel from $17.99.
32GB microSD — $13.69
I’m using the official Raspberry Pi A2-class 32GB card.
You don’t specifically need that card. A decent 16GB+ microSD you already own will work.
5V 10A LED power supply — $29.95
Each Waveshare panel is specified for 5V operation and up to 2.5A. Rather than sizing our supply right on the theoretical maximum for two panels, I’m using a 5V 10A supply with plenty of headroom.
Raspberry Pi power supply — $8.80
I’m powering the Pi separately using the official 5.1V 2.5A micro-USB supply.
You’ll also need the HUB75 ribbon cables and panel power leads. The Waveshare panels include the relevant cables and adapters.
And eventually we’ll need an enclosure.
But first let’s make some lights blink.
A quick warning about power
LED matrices aren’t little status LEDs.
They can draw serious current.
Do not power both panels from the Raspberry Pi.
We’re giving the matrices their own regulated 5V supply.
We’re giving the Pi its own micro-USB supply.
Before powering anything:
- confirm the supply is 5V;
- confirm polarity;
- make sure ground is wired correctly;
- connect everything with power disconnected;
- check the markings on your actual panel rather than blindly trusting an internet diagram.
The Waveshare panels we’re using are specified at 5V/2.5A each.
If you buy different panels, use their specifications.
Electronics projects are considerably more enjoyable before the smoke escapes.
⸻
Step 1: Install Raspberry Pi OS
We’re going headless, so there’s no reason to install a desktop.
Install Raspberry Pi Imager on your normal computer and insert the microSD card.
Choose:
Device:
Raspberry Pi Zero 2 W
OS:
Raspberry Pi OS Lite (64-bit)
Storage:
Your microSD card
Use Imager’s OS customization to configure:
Hostname: pickleboard
Username: John
Wi-Fi: your network
SSH: enabled
Use your own username instead of john.
Unless your name is John.
In which case this tutorial is becoming suspiciously convenient.
Write the image, insert the card and power up the Pi.
After it boots:
ssh john@pickleboard.local
Then:
sudo apt update
sudo apt full-upgrade -y
I also like checking exactly what OS I’m running:
cat /etc/os-release
uname -a
If you’re reading this tutorial years from now and something has broken, that output will probably become relevant.
⸻
Step 2: Wire the display
Shut everything down first.
The Matrix Bonnet plugs directly onto the Pi Zero 2 W’s 40-pin GPIO header.
Then the data path is:
Pi Zero 2 W
↓
RGB Matrix Bonnet
↓
HUB75
↓
Panel 1 INPUT
Panel 1 OUTPUT
↓
HUB75
↓
Panel 2 INPUT
Both LED panels are connected to the external 5V supply.
The Pi has its own supply.
Pay attention to the INPUT and OUTPUT markings on the panels.
HUB75 is directional.
Connect the first cable backward and nothing particularly exciting happens.
Nothing happens at all.
Ask me how I know.
Step 3: Install the LED matrix driver
We’re using Henner Zeller’s excellent rpi-rgb-led-matrix project to actually drive the panels.
Install the dependencies:
sudo apt install -y \
git \
build-essential \
python3-dev \
python3-pillow \
python3-venv \
cython3 \
python3-setuptools
Clone the project:
cd ~
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git
cd rpi-rgb-led-matrix
Build:
make -j2
Now create a Python virtual environment:
cd ~
python3 -m venv pickleboard-env --system-site-packages
source pickleboard-env/bin/activate
Build and install the Python bindings:
cd ~/rpi-rgb-led-matrix
make build-python
make install-python
Now let’s find out whether we’ve built a scoreboard or a very expensive black rectangle.
Step 4: Test one panel
Start with one panel.
Seriously.
Don’t connect both and introduce twice as many possible reasons for nothing to work.
Go to:
cd ~/rpi-rgb-led-matrix/examples-api-use
Run a demo:
sudo ./demo \
--led-rows=32 \
--led-cols=64 \
--led-gpio-mapping=adafruit-hat \
-D 0
You should see output.
If you don’t:
Is the panel powered?
Is HUB75 connected to INPUT?
Is the Bonnet properly seated?
Is the ribbon cable oriented correctly?
Are you definitely using a compatible HUB75 panel?
Do not continue until panel 1 works.
Add panel 2
Now connect panel 1’s output to panel 2’s input.
Run:
sudo ./demo \
--led-rows=32 \
--led-cols=64 \
--led-chain=2 \
--led-gpio-mapping=adafruit-hat \
-D 0
The driver now treats them as one canvas:
64 + 64 = 128 pixels
So we have a 128×32 display.
Step 5: Draw something
Create our project:
mkdir -p ~/pickleboard
cd ~/pickleboard
nano test_display.py
Add:
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
options = RGBMatrixOptions()
options.rows = 32
options.cols = 64
options.chain_length = 2
options.hardware_mapping = "adafruit-hat"
options.gpio_slowdown = 2
options.brightness = 45
matrix = RGBMatrix(options=options)
canvas = matrix.CreateFrameCanvas()
amber = graphics.Color(255, 150, 0)
red = graphics.Color(255, 30, 20)
font = graphics.Font()
font.LoadFont(
"/home/john/rpi-rgb-led-matrix/fonts/6x10.bdf"
)
canvas.Clear()
graphics.DrawText(canvas, font, 1, 9, red, "LIVE")
graphics.DrawText(canvas, font, 35, 9, amber, "JOHNS")
graphics.DrawText(canvas, font, 111, 9, amber, "11")
graphics.DrawText(canvas, font, 35, 19, amber, "COLLIN")
graphics.DrawText(canvas, font, 117, 19, amber, "8")
graphics.DrawText(
canvas,
font,
35,
30,
amber,
"PICKLEBALL OPEN",
)
matrix.SwapOnVSync(canvas)
input("Press Enter to exit...")
Change /home/john/ to your username.
Then:
cd ~/pickleboard
source ~/pickleboard-env/bin/activate
sudo -E env PATH=$PATH \
python3 test_display.py
And there it is.
Our pickleball departure board.
There is only one slight problem.
We made all the scores up.
Step 6: Keep API data away from display code
We could fetch some JSON and then scatter things like this throughout our rendering code:
data["matches"][0]["players"][0]["name"]
Don’t.
Our display shouldn’t care what Pickleball API’s response looks like.
Instead, we want to turn the API response into a tiny internal model:
{
"status": "LIVE",
"player1": "JOHNS",
"player2": "COLLIN",
"score1": 11,
"score2": 8,
"event": "PICKLEBALL OPEN",
"round": "QF",
}
Then we have three distinct jobs:
FETCH
Get data from the outside world.
NORMALIZE
Turn it into our schema.
RENDER
Turn our schema into pixels.
The rendering code doesn’t know where the score came from.
That’s going to become useful later.
Step 7: Fetch real data
Install requests:
source ~/pickleboard-env/bin/activate
pip install requests
Create:
nano ~/pickleboard/scoreboard.py
Start with:
import logging
import os
import time
import requests
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
Configuration:
API_URL = os.environ["PICKLEBALL_API_URL"]
API_KEY = os.environ["PICKLEBALL_API_KEY"]
POLL_SECONDS = 15
Fetch:
def fetch_match():
response = requests.get(
API_URL,
headers={
"Authorization": f"Bearer {API_KEY}",
"Accept": "application/json",
},
timeout=10,
)
response.raise_for_status()
return response.json()
Use the authentication method and endpoint shown in the current Pickleball API documentation for your account.
Now normalize:
def normalize_match(payload):
match = payload.get("data", payload)
players = match["players"]
return {
"status": str(
match.get("status", "LIVE")
).upper(),
"player1": players[0]["name"]
.split()[-1]
.upper(),
"player2": players[1]["name"]
.split()[-1]
.upper(),
"score1": str(players[0].get("score", "")),
"score2": str(players[1].get("score", "")),
"event": str(
match.get("event", "PICKLEBALL")
).upper(),
"round": str(
match.get("round", "")
).upper(),
}
This function is our adapter.
If the API response changes, this is the bit we fix.
Our LED renderer doesn’t care.
Step 8: Render the match
Create the matrix:
def create_matrix():
options = RGBMatrixOptions()
options.rows = 32
options.cols = 64
options.chain_length = 2
options.hardware_mapping = "adafruit-hat"
options.gpio_slowdown = 2
options.brightness = 45
return RGBMatrix(options=options)
Load our font:
FONT_PATH = (
"/home/john/"
"rpi-rgb-led-matrix/fonts/6x10.bdf"
)
font = graphics.Font()
font.LoadFont(FONT_PATH)
Colors:
AMBER = graphics.Color(255, 150, 0)
RED = graphics.Color(255, 30, 20)
And let’s deal with pickleball players who inconsiderately have long surnames:
def compact_name(name, length=11):
name = name.upper().strip()
if len(name) <= length:
return name
return name[:length]
Then render:
def render_match(matrix, match):
canvas = matrix.CreateFrameCanvas()
canvas.Clear()
status_colour = (
RED
if match["status"] == "LIVE"
else AMBER
)
graphics.DrawText(
canvas,
font,
1,
9,
status_colour,
match["status"][:5],
)
graphics.DrawText(
canvas,
font,
35,
9,
AMBER,
compact_name(match["player1"]),
)
graphics.DrawText(
canvas,
font,
111,
9,
AMBER,
match["score1"],
)
graphics.DrawText(
canvas,
font,
35,
19,
AMBER,
compact_name(match["player2"]),
)
graphics.DrawText(
canvas,
font,
111,
19,
AMBER,
match["score2"],
)
footer = " · ".join(
value
for value in [
match["event"],
match["round"],
]
if value
)
graphics.DrawText(
canvas,
font,
1,
30,
AMBER,
footer[:21],
)
matrix.SwapOnVSync(canvas)
We’ve now built:
API JSON
↓
normalize_match()
↓
our schema
↓
render_match()
↓
pixels
Step 9: What happens when there isn’t a match?
A physical display needs to communicate more than just success.
At minimum, ours needs:
CONNECTING
LIVE
FINAL
NO LIVE MATCH
OFFLINE
Create:
def render_message(
matrix,
line1,
line2="",
colour=AMBER,
):
canvas = matrix.CreateFrameCanvas()
canvas.Clear()
graphics.DrawText(
canvas,
font,
8,
14,
colour,
line1[:18],
)
if line2:
graphics.DrawText(
canvas,
font,
8,
26,
colour,
line2[:18],
)
matrix.SwapOnVSync(canvas)
So now we can display:
render_message(
matrix,
"OFFLINE",
"RETRYING...",
)
instead of freezing forever at an old score.
Step 10: The main loop
Now everything gets pleasantly boring:
def main():
matrix = create_matrix()
render_message(
matrix,
"CONNECTING...",
)
while True:
try:
payload = fetch_match()
if not payload:
render_message(
matrix,
"NO LIVE MATCH",
"CHECK BACK LATER",
)
else:
match = normalize_match(payload)
render_match(matrix, match)
except requests.RequestException:
logging.exception(
"API request failed"
)
render_message(
matrix,
"OFFLINE",
"RETRYING...",
)
except Exception:
logging.exception(
"Unexpected scoreboard error"
)
render_message(
matrix,
"ERROR",
"CHECK LOGS",
)
time.sleep(POLL_SECONDS)
if __name__ == "__main__":
logging.basicConfig(
level=logging.INFO,
format=(
"%(asctime)s "
"%(levelname)s "
"%(message)s"
),
)
main()
Run:
cd ~/pickleboard
source ~/pickleboard-env/bin/activate
export PICKLEBALL_API_URL="YOUR_API_ENDPOINT"
export PICKLEBALL_API_KEY="YOUR_API_KEY"
sudo -E env \
PATH=$PATH \
PICKLEBALL_API_URL="$PICKLEBALL_API_URL" \
PICKLEBALL_API_KEY="$PICKLEBALL_API_KEY" \
python3 scoreboard.py
And our data has now traveled:
internet
↓
HTTP
↓
JSON
↓
Python
↓
our match model
↓
framebuffer
↓
GPIO
↓
HUB75
↓
4,096 tiny LEDs
Excellent use of technology.
Step 11: Make it an appliance
There’s one remaining problem.
We currently need to SSH into our scoreboard and start it.
That’s not an appliance.
The goal is:
plug it in
↓
wait
↓
pickleball
Create:
sudo nano /etc/pickleboard.env
Add:
PICKLEBALL_API_URL=YOUR_API_ENDPOINT
PICKLEBALL_API_KEY=YOUR_API_KEY
Lock it down:
sudo chmod 600 /etc/pickleboard.env
Now:
sudo nano \
/etc/systemd/system/pickleboard.service
Add:
[Unit]
Description=Pickleball LED Scoreboard
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
EnvironmentFile=/etc/pickleboard.env
WorkingDirectory=/home/john/pickleboard
ExecStart=/home/john/pickleboard-env/bin/python3 \
/home/john/pickleboard/scoreboard.py
Restart=always
RestartSec=5
User=root
[Install]
WantedBy=multi-user.target
Replace john with your username.
Then:
sudo systemctl daemon-reload
sudo systemctl enable pickleboard
sudo systemctl start pickleboard
Check it:
sudo systemctl status pickleboard
Logs:
sudo journalctl \
-u pickleboard \
-f
Now reboot:
sudo reboot
And don’t SSH back in.
Wait.
The pixels should light up by themselves.
That’s the moment this stops feeling like a Python experiment and starts feeling like a device.
Step 12: Hide everything in a box
Right now we’ve created:
sophisticated distributed sports-data system
but visually we have:
pile of circuit boards
So the final step is an enclosure.
Nothing particularly clever is required:
front bezel
2 × LED panels
mounting plate
Pi Zero 2 W
+
Matrix Bonnet
power connections
ventilation
rear cover
I’m aiming for a plain matte-black enclosure.
No buttons.
No display surround graphics.
No logo.
Just the amber matrix.
From the outside, there’s no clue that a $20 Linux computer inside is polling an API over Wi-Fi.
Which is exactly how I want it.
Done
We now have a physical pickleball scoreboard that:
- boots headlessly;
- connects to Wi-Fi;
- fetches live sports data;
- normalizes that data into our own schema;
- renders a 128×32 interface;
- drives two chained HUB75 panels;
- has basic error states;
- and starts automatically after a reboot.
The core hardware is around $125 at current US prices, before tax, shipping and whatever you decide to do for an enclosure.
And we’ve somehow turned a REST API into railway infrastructure.
Unfortunately, it’s terrible
It works.
But operationally? It’s terrible.
What happens if the API fails once?
What happens if Wi-Fi disappears for two minutes?
What happens if there are four live matches?
What happens when someone with a 16-character surname reaches the final?
What if the Pi boots before the network is ready?
What happens if the API is unavailable for half an hour?
Right now our answer to most of those questions is:
Try again in 15 seconds.
Which is fine for a prototype.
It’s not how I’d want something that lives on my desk to behave.
So next time, we’re going to make it much harder to kill.
We’ll add caching, exponential backoff, multiple-match rotation, better offline behavior, automatic brightness, sensible handling for long text, improved logging and graceful recovery.
Because getting JSON onto LEDs is the fun part.
Getting it to keep working is the engineering part.
⸻
I built pickleball-api.com because I wanted professional pickleball data that developers could actually build things with.
Apparently what I wanted to build was a train departure board.
If you connect the API to something even more unnecessary, I’d genuinely love to see it.
🏓









Top comments (0)