DEV Community

Cover image for I built a Live Pickleball Score Board with a $20 Raspberry Pi
John Sitherstone
John Sitherstone

Posted on

I built a Live Pickleball Score Board with a $20 Raspberry Pi

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.

The finished board. Entirely unnecessary. Therefore essential.

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
Enter fullscreen mode Exit fullscreen mode

Behind it is this pipeline:

Pickleball API
      ↓
fetch live match
      ↓
normalize data
      ↓
choose layout
      ↓
render pixels
      ↓
HUB75 matrix
Enter fullscreen mode Exit fullscreen mode

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.

The entire system. The Raspberry Pi handles both fetching the data and rendering the display.

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
Enter fullscreen mode Exit fullscreen mode

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.

The major components. Using the Zero 2 W with its GPIO header already fitted means we can build this without soldering.

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
Enter fullscreen mode Exit fullscreen mode

Use Imager’s OS customization to configure:

Hostname: pickleboard
Username: John
Wi-Fi: your network
SSH: enabled
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

I also like checking exactly what OS I’m running:

cat /etc/os-release
uname -a
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Clone the project:

cd ~
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git
cd rpi-rgb-led-matrix
Enter fullscreen mode Exit fullscreen mode

Build:

make -j2

Now create a Python virtual environment:

cd ~
python3 -m venv pickleboard-env --system-site-packages
source pickleboard-env/bin/activate
Enter fullscreen mode Exit fullscreen mode

Build and install the Python bindings:

cd ~/rpi-rgb-led-matrix
make build-python
make install-python
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The driver now treats them as one canvas:

64 + 64 = 128 pixels

So we have a 128×32 display.

Our target: a 128×32 fake departure board.

Step 5: Draw something

Create our project:

mkdir -p ~/pickleboard
cd ~/pickleboard
nano test_display.py
Enter fullscreen mode Exit fullscreen mode

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...")
Enter fullscreen mode Exit fullscreen mode

Change /home/john/ to your username.

Then:

cd ~/pickleboard
source ~/pickleboard-env/bin/activate
sudo -E env PATH=$PATH \
  python3 test_display.py
Enter fullscreen mode Exit fullscreen mode

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",
}
Enter fullscreen mode Exit fullscreen mode

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.

Our display renders an internal match model, not raw API JSON.

Step 7: Fetch real data

Install requests:

source ~/pickleboard-env/bin/activate
pip install requests
Enter fullscreen mode Exit fullscreen mode

Create:

nano ~/pickleboard/scoreboard.py

Start with:

import logging
import os
import time
import requests
from rgbmatrix import RGBMatrix, RGBMatrixOptions, graphics
Enter fullscreen mode Exit fullscreen mode

Configuration:

API_URL = os.environ["PICKLEBALL_API_URL"]
API_KEY = os.environ["PICKLEBALL_API_KEY"]
POLL_SECONDS = 15
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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(),
    }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Load our font:

FONT_PATH = (
    "/home/john/"
    "rpi-rgb-led-matrix/fonts/6x10.bdf"
)
font = graphics.Font()
font.LoadFont(FONT_PATH)
Enter fullscreen mode Exit fullscreen mode

Colors:

AMBER = graphics.Color(255, 150, 0)
RED = graphics.Color(255, 30, 20)
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

We’ve now built:

API JSON
   ↓
normalize_match()
   ↓
our schema
   ↓
render_match()
   ↓
pixels
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

So now we can display:

render_message(
    matrix,
    "OFFLINE",
    "RETRYING...",
)
Enter fullscreen mode Exit fullscreen mode

instead of freezing forever at an old score.

A physical device needs sensible states when the outside world isn’t cooperating.

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And our data has now traveled:

internet
   ↓
HTTP
   ↓
JSON
   ↓
Python
   ↓
our match model
   ↓
framebuffer
   ↓
GPIO
   ↓
HUB75
   ↓
4,096 tiny LEDs
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Create:

sudo nano /etc/pickleboard.env

Add:

PICKLEBALL_API_URL=YOUR_API_ENDPOINT
PICKLEBALL_API_KEY=YOUR_API_KEY
Enter fullscreen mode Exit fullscreen mode

Lock it down:

sudo chmod 600 /etc/pickleboard.env

Now:

sudo nano \
  /etc/systemd/system/pickleboard.service
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Replace john with your username.

Then:

sudo systemctl daemon-reload
sudo systemctl enable pickleboard
sudo systemctl start pickleboard
Enter fullscreen mode Exit fullscreen mode

Check it:

sudo systemctl status pickleboard

Logs:

sudo journalctl \
  -u pickleboard \
  -f
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Turning “pile of electronics” into “object.”

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.

API → pixels.

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)