Build an ESP32 live scoreboard
A desk scoreboard: an ESP32 reads the league scoreboard over HTTPS, keeps only the fields it shows, picks your team's game and refreshes every 45 seconds while it's live and rarely otherwise.
- Stack
- ESP32, Arduino, ArduinoJson, SSD1306
- Plan
- Starter for live updates all season; Free for a few test games
- Code
- esp32-scoreboard/ (MIT)
Updated 2026-10-18. Game-picking and polling logic unit tested natively on that date; the sketch itself is not compiled in CI. Data is aggregated from public sources and is typically 20-30 seconds behind live play (about 1 second after our source). There is no SLA.
1. Get a free API key
Sign up (no card) and copy the key from the dashboard. The Free plan gives you 1,000 calls in the first 30 days, then 125 a month, at 1 request per second. Every successful REST call counts as one call.
Check the key works with one call to the live-scores endpoint:
Terminal
export REALTIME_SPORTS_API_KEY=your_key
curl -H "Authorization: Bearer $REALTIME_SPORTS_API_KEY" \
"https://www.realtimesportsapi.com/api/v1/sports/football/leagues/nfl/events/live"2. Get the code
Everything below is in the esp32-scoreboard/ folder of the examples repo (MIT). Clone it and work from that folder.
Terminal
git clone https://github.com/ElcoDevRepos/realtime-sports-api-examples
cd realtime-sports-api-examples/esp32-scoreboard3. Parts and wiring
Any ESP32 dev board and an SSD1306 128x64 I2C OLED at address 0x3C: SDA to GPIO 21, SCL to GPIO 22, VCC to 3V3, GND to GND. In Arduino IDE 2 install the esp32 core (3.x) and the ArduinoJson 7, Adafruit SSD1306 and Adafruit GFX libraries. Copy secrets.h.example to secrets.h with your Wi-Fi, key, sport, league and team.
4. Board-independent logic
scoreboard_logic.h is plain C++ with no Arduino headers: parseIsoUtc turns the API's UTC dates into Unix time, pickGame chooses a live game first, then the next upcoming one, then the last final, and nextPollMs decides the next call: 45 seconds while live, from 5 minutes before a start, 30 minutes after a final, hourly with no game.
esp32-scoreboard/scoreboard_logic.h
// Board-independent scoreboard logic: picking the game to show, formatting it and deciding when to
// poll again. Plain C++ (no Arduino headers) so test/test_logic.cpp can run it on your computer.
#pragma once
#include <stdio.h>
#include <string.h>
#include <ctype.h>
#include <stdint.h>
struct Game {
char away[8];
char home[8];
int awayScore;
int homeScore;
char state[6]; // "pre", "in" or "post"
char detail[40]; // "5:23 - 2nd Quarter", "Final", "Tue, October 20th at 7:00 PM EDT"
int64_t start; // kickoff/tip-off, Unix seconds (0 if unknown)
};
// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
inline int64_t daysFromCivil(int y, unsigned m, unsigned d) {
y -= m <= 2;
const int64_t era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = (unsigned)(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + (int64_t)doe - 719468;
}
// "2026-10-20T23:00Z" or "2026-10-20T23:00:00Z" (the API's UTC event dates) -> Unix seconds, 0 on error.
inline int64_t parseIsoUtc(const char* s) {
int y, mo, d, h, mi, sec = 0;
if (!s || sscanf(s, "%4d-%2d-%2dT%2d:%2d", &y, &mo, &d, &h, &mi) != 5) return 0;
if (strlen(s) > 16 && s[16] == ':') sscanf(s + 17, "%2d", &sec);
return daysFromCivil(y, mo, d) * 86400 + h * 3600 + mi * 60 + sec;
}
inline bool equalsIgnoreCase(const char* a, const char* b) {
for (; *a && *b; a++, b++) if (tolower((unsigned char)*a) != tolower((unsigned char)*b)) return false;
return *a == *b;
}
inline bool involves(const Game& g, const char* team) {
return !team || !*team || equalsIgnoreCase(g.away, team) || equalsIgnoreCase(g.home, team);
}
// Index of the game to show for `team`: a live game first, then the next upcoming one, then the
// most recent final. -1 when the team has no game on the scoreboard.
inline int pickGame(const Game* games, int n, const char* team, int64_t now) {
int live = -1, next = -1, last = -1;
for (int i = 0; i < n; i++) {
const Game& g = games[i];
if (!involves(g, team)) continue;
if (strcmp(g.state, "in") == 0) { if (live < 0) live = i; }
else if (strcmp(g.state, "pre") == 0) { if (g.start + 3600 >= now && (next < 0 || g.start < games[next].start)) next = i; }
else if (last < 0 || g.start > games[last].start) last = i;
}
return live >= 0 ? live : next >= 0 ? next : last;
}
// Big line for the display: "BOS 99 DET 104", or "BOS @ DET" before the game.
inline void scoreLine(const Game& g, char* out, size_t size) {
if (strcmp(g.state, "pre") == 0) snprintf(out, size, "%s @ %s", g.away, g.home);
else snprintf(out, size, "%s %d %s %d", g.away, g.awayScore, g.home, g.homeScore);
}
// Milliseconds until the next API call: 45 s while live; before a game, until 5 minutes before
// the start (at least 1 min, at most 30 min); 30 min after a final; 60 min with no game.
inline uint32_t nextPollMs(const Game* g, int64_t now) {
if (!g) return 60UL * 60 * 1000;
if (strcmp(g->state, "in") == 0) return 45UL * 1000;
if (strcmp(g->state, "pre") == 0) {
int64_t until = g->start - 300 - now;
if (until < 60) until = 60;
if (until > 1800) until = 1800;
return (uint32_t)until * 1000;
}
return 30UL * 60 * 1000;
}
5. The sketch
fetchScoreboard sets useHTTP10(true) so the response is not chunked, then lets ArduinoJson filter the stream down to abbreviations, scores, status and date, so a full scoreboard fits in the ESP32's memory. The clock comes from NTP. setInsecure() keeps TLS setup short; pin the root CA with setCACert() for anything sensitive.
esp32-scoreboard/esp32-scoreboard.ino
// ESP32 live scoreboard on a 128x64 SSD1306 OLED, powered by the Realtime Sports API.
//
// Board: any ESP32 dev board (Arduino core for ESP32 3.x). Libraries (Library Manager):
// ArduinoJson 7.x, Adafruit SSD1306, Adafruit GFX.
// Wiring (I2C): OLED SDA -> GPIO 21, SCL -> GPIO 22, VCC -> 3V3, GND -> GND.
//
// One HTTPS call to the league scoreboard (GET /sports/{sport}/leagues/{league}/events) per
// refresh: every 45 s while your team is live, otherwise only when a game is near (see
// scoreboard_logic.h). The JSON is filtered while it streams in, so the board only keeps the few
// fields it shows.
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <time.h>
#include "secrets.h"
#include "scoreboard_logic.h"
const char* API_BASE = "https://www.realtimesportsapi.com/api/v1";
const int MAX_GAMES = 24;
Adafruit_SSD1306 display(128, 64, &Wire, -1);
Game games[MAX_GAMES];
int gameCount = 0;
uint32_t nextPollAt = 0;
void show(const char* top, const char* big, const char* bottom) {
display.clearDisplay();
display.setTextColor(SSD1306_WHITE);
display.setTextSize(1);
display.setCursor(0, 0);
display.print(top);
display.setTextSize(strlen(big) > 10 ? 1 : 2);
display.setCursor(0, 22);
display.print(big);
display.setTextSize(1);
display.setCursor(0, 54);
display.print(bottom);
display.display();
}
// Returns true when the scoreboard was read. Fills games[] / gameCount.
bool fetchScoreboard() {
WiFiClientSecure client;
// setInsecure() skips certificate checks: fine for a desk scoreboard. For anything sensitive,
// pin the site's root CA with client.setCACert(...) instead.
client.setInsecure();
HTTPClient http;
String url = String(API_BASE) + "/sports/" + RSA_SPORT + "/leagues/" + RSA_LEAGUE + "/events?limit=" + MAX_GAMES;
http.useHTTP10(true); // no chunked encoding, so ArduinoJson can read the stream directly
if (!http.begin(client, url)) return false;
http.addHeader("Authorization", String("Bearer ") + RSA_API_KEY);
int code = http.GET();
if (code != 200) {
Serial.printf("HTTP %d\n", code);
show(RSA_LEAGUE, code == 429 ? "Quota" : "Error", code == 401 ? "check API key" : "retrying");
http.end();
return false;
}
// Keep only the fields we display.
JsonDocument filter;
JsonObject f = filter["data"][0].to<JsonObject>();
f["date"] = true;
f["status"]["state"] = true;
f["status"]["detail"] = true;
f["homeTeam"]["abbreviation"] = true;
f["homeTeam"]["score"] = true;
f["awayTeam"]["abbreviation"] = true;
f["awayTeam"]["score"] = true;
JsonDocument doc;
DeserializationError err = deserializeJson(doc, http.getStream(), DeserializationOption::Filter(filter));
http.end();
if (err) {
Serial.printf("JSON error: %s\n", err.c_str());
return false;
}
gameCount = 0;
for (JsonObject e : doc["data"].as<JsonArray>()) {
if (gameCount >= MAX_GAMES) break;
Game& g = games[gameCount++];
snprintf(g.away, sizeof g.away, "%s", e["awayTeam"]["abbreviation"] | "?");
snprintf(g.home, sizeof g.home, "%s", e["homeTeam"]["abbreviation"] | "?");
g.awayScore = e["awayTeam"]["score"] | 0;
g.homeScore = e["homeTeam"]["score"] | 0;
snprintf(g.state, sizeof g.state, "%s", e["status"]["state"] | "pre");
snprintf(g.detail, sizeof g.detail, "%s", e["status"]["detail"] | "");
g.start = parseIsoUtc(e["date"] | "");
}
return true;
}
void setup() {
Serial.begin(115200);
Wire.begin(21, 22);
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) Serial.println("SSD1306 not found at 0x3C");
show("Realtime Sports API", "WiFi...", WIFI_SSID);
WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
while (WiFi.status() != WL_CONNECTED) delay(250);
configTime(0, 0, "pool.ntp.org", "time.nist.gov"); // UTC clock, to know how far away a game is
while (time(nullptr) < 1700000000) delay(250);
}
void loop() {
if ((int32_t)(millis() - nextPollAt) < 0) {
delay(200);
return;
}
int64_t now = time(nullptr);
if (!fetchScoreboard()) {
nextPollAt = millis() + 60000;
return;
}
int i = pickGame(games, gameCount, RSA_TEAM, now);
const Game* g = i >= 0 ? &games[i] : nullptr;
if (g) {
char line[24];
scoreLine(*g, line, sizeof line);
show(strcmp(g->state, "in") == 0 ? "LIVE" : strcmp(g->state, "post") == 0 ? "FINAL" : "NEXT", line, g->detail);
Serial.printf("%s | %s\n", line, g->detail);
} else {
show(RSA_LEAGUE, "No game", "on the scoreboard");
}
nextPollAt = millis() + nextPollMs(g, now);
}
6. Test and flash
Run the native tests on your computer, then upload from the Arduino IDE and watch the serial monitor at 115200 baud.
Terminal
c++ -std=c++17 -Wall -o /tmp/scoreboard-test test/test_logic.cpp && /tmp/scoreboard-test7. Quota
Hourly checks with no game cost about 24 calls a day; one NBA or NHL game live is about 200 calls at 45 seconds, so one team is roughly 3,500 calls a month (Starter, 10,000 calls). For the Free plan, raise the live interval to a few minutes and the idle interval to several hours. Data is typically 20-30 seconds behind live play.
Endpoints used
GET /sports/{sport}/leagues/{league}/events
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- Does it work on an ESP8266?
- The logic and JSON filtering do, but you need to swap the WiFi, HTTPClient and TLS headers for the ESP8266 equivalents and watch memory closely.
- Can I use an LED matrix instead?
- Yes, replace show() with your display library. For a Raspberry Pi RGB matrix, see the LED scoreboard guide.
- Does it show team logos?
- No. It shows abbreviations, scores and the clock, which is what fits a 128x64 screen.