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

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-scoreboard

3. 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-test

7. 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.

More guides