Add a live score overlay to your stream

Run one Node server, add one browser source, and your stream shows your team's live score and clock, the start time before the game and the final afterwards, with a flash when a team scores.

Stack
OBS, Node.js, HTML
Plan
Free for a couple of test streams; Starter for weekly streams
Code
obs-overlay/ (MIT)

Updated 2026-10-18. Tested against live NHL and NBA scoreboards on that date. 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 obs-overlay/ 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/obs-overlay

3. Pick the game and cache it

pickGame chooses your team's game from the scoreboard: live first, then the next upcoming one, then the last final. toOverlay reduces it to abbreviations, scores, team colours and the status line (no logos). ttlFor caches 30 seconds while live, until about 5 minutes before the start when upcoming, and 10 minutes after a final, and createCache coalesces simultaneous requests into one upstream call.

obs-overlay/lib.js

// Pure helpers for the OBS score overlay server (plus apiGet). Unit tested with `node --test`.
import { existsSync, readFileSync } from 'node:fs';

export const API_BASE = process.env.RSA_API_BASE || 'https://www.realtimesportsapi.com/api/v1';

/** Minimal .env loader (KEY=value lines). Variables already in the environment win. */
export function loadDotEnv(path) {
  if (!existsSync(path)) return;
  for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
    if (line.trimStart().startsWith('#')) continue;
    const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
    if (m && process.env[m[1]] === undefined) process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2');
  }
}

/** GET a path under /api/v1 and return the envelope { success, data, meta }. Throws on non-2xx. */
export async function apiGet(path, key, query = {}) {
  const url = new URL(API_BASE + path);
  for (const [k, v] of Object.entries(query)) if (v != null) url.searchParams.set(k, String(v));
  const res = await fetch(url, { headers: { Authorization: `Bearer ${key}`, Accept: 'application/json' } });
  const body = await res.json().catch(() => null);
  if (!res.ok) {
    const err = new Error(`HTTP ${res.status} ${body?.error?.code ?? ''}: ${body?.error?.message ?? 'request failed'}`);
    err.status = res.status;
    throw err;
  }
  return body;
}

/** Only these characters reach the upstream path: lowercase slugs such as "basketball", "eng.1". */
export const SLUG = /^(?!.*\.\.)[a-z0-9][a-z0-9.-]{1,39}$/;

const isTeam = (t, team) => {
  const q = String(team).toLowerCase();
  return t && (String(t.id) === q || String(t.abbreviation ?? '').toLowerCase() === q);
};

/**
 * The game to show for `team` from a scoreboard (/events) response: a live game first, then the
 * next upcoming one, then the most recent final. Without a team, the first live game, else the first game.
 */
export function pickGame(events, team, now = Date.now()) {
  const games = (events ?? []).filter((g) => !team || isTeam(g.homeTeam, team) || isTeam(g.awayTeam, team));
  const by = (state) => games.filter((g) => g.status?.state === state);
  const live = by('in');
  if (live.length) return live[0];
  const pre = by('pre').filter((g) => Date.parse(g.date) >= now - 3_600_000).sort((a, b) => Date.parse(a.date) - Date.parse(b.date));
  if (pre.length && team) return pre[0];
  const post = by('post').sort((a, b) => Date.parse(b.date) - Date.parse(a.date));
  if (post.length && team) return post[0];
  return games[0] ?? null;
}

/** The small object the overlay page renders. No logos: abbreviations and team colours only. */
export function toOverlay(game) {
  if (!game) return { state: 'none' };
  const side = (t) => ({ abbr: t?.abbreviation || t?.name || '?', score: t?.score ?? 0, color: t?.color ? `#${t.color}` : null });
  return {
    id: game.id,
    state: game.status?.state ?? 'pre',
    detail: game.status?.detail ?? '',
    away: side(game.awayTeam),
    home: side(game.homeTeam)
  };
}

/** How long to cache a result: 30 s while live, until ~5 min before tip-off when upcoming (max 10 min), 10 min after a final. */
export function ttlFor(game, now = Date.now()) {
  const state = game?.status?.state;
  if (state === 'in') return 30_000;
  if (state === 'pre') {
    const until = Date.parse(game.date) - now - 5 * 60_000;
    return Math.max(30_000, Math.min(Number.isFinite(until) ? until : 600_000, 600_000));
  }
  return 600_000;
}

/** Tiny TTL cache that also coalesces concurrent misses into one upstream request. */
export function createCache(now = () => Date.now()) {
  const store = new Map();
  return async function cached(key, load) {
    const hit = store.get(key);
    if (hit && (hit.pending || hit.expires > now())) return hit.pending ?? hit.value;
    const pending = load().then(
      ({ value, ttl }) => {
        store.set(key, { value, expires: now() + ttl });
        return value;
      },
      (err) => {
        store.delete(key);
        throw err;
      }
    );
    store.set(key, { pending, expires: 0 });
    return pending;
  };
}

4. The local server

server.js serves the overlay at / and JSON at /score?league=nba&team=BOS. It listens on 127.0.0.1 by default, accepts only plain league and sport slugs, and is the only thing that sees your key.

obs-overlay/server.js

// Score overlay for OBS (or any streaming tool with a browser source). Node 18+, no dependencies.
//
//   node server.js     serves http://localhost:3030/?league=nba&team=BOS
//
// The browser source only talks to this server, so your API key never leaves your machine, and
// every viewer/scene shares one cached upstream request (30 s while live, longer otherwise).
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { SLUG, apiGet, createCache, loadDotEnv, pickGame, toOverlay, ttlFor } from './lib.js';

loadDotEnv(new URL('./.env', import.meta.url));
loadDotEnv(new URL('../.env', import.meta.url));

const KEY = process.env.REALTIME_SPORTS_API_KEY;
const PORT = Number(process.env.PORT || 3030);
const HOST = process.env.HOST || '127.0.0.1'; // local only by default
if (!KEY || KEY === 'your_api_key_here') {
  console.error('Set REALTIME_SPORTS_API_KEY (see .env.example). Free key: https://www.realtimesportsapi.com/signup');
  process.exit(1);
}

const overlayHtml = readFileSync(new URL('./public/overlay.html', import.meta.url));
const cached = createCache();
const SPORT_OF = { nfl: 'football', 'college-football': 'football', nba: 'basketball', 'mens-college-basketball': 'basketball', mlb: 'baseball', nhl: 'hockey' };

function send(res, status, body, type = 'application/json') {
  res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' });
  res.end(typeof body === 'string' || Buffer.isBuffer(body) ? body : JSON.stringify(body));
}

http
  .createServer(async (req, res) => {
    const url = new URL(req.url, `http://${req.headers.host}`);
    if (url.pathname === '/' || url.pathname === '/overlay') return send(res, 200, overlayHtml, 'text/html; charset=utf-8');
    if (url.pathname !== '/score') return send(res, 404, { error: 'not found' });

    const league = (url.searchParams.get('league') || 'nba').toLowerCase();
    const sport = (url.searchParams.get('sport') || SPORT_OF[league] || 'soccer').toLowerCase();
    const team = (url.searchParams.get('team') || '').slice(0, 40);
    if (!SLUG.test(league) || !SLUG.test(sport)) return send(res, 400, { error: 'bad sport or league' });

    try {
      const game = await cached(`${sport}/${league}/${team.toLowerCase()}`, async () => {
        const { data } = await apiGet(`/sports/${sport}/leagues/${league}/events`, KEY);
        const picked = pickGame(data, team);
        console.error(new Date().toISOString(), `fetched ${sport}/${league}${team ? ` ${team}` : ''}: ${picked?.shortName ?? 'no game'}`);
        return { value: toOverlay(picked), ttl: ttlFor(picked) };
      });
      send(res, 200, game);
    } catch (err) {
      console.error(err.message);
      send(res, err.status === 429 ? 429 : 502, { error: err.status === 429 ? 'monthly quota used up' : 'upstream error' });
    }
  })
  .listen(PORT, HOST, () => console.error(`Overlay on http://${HOST}:${PORT}/?league=nba&team=BOS`));

5. The overlay page

The page has a transparent background, refreshes from the local cache every 15 seconds, flashes a team's box when it scores and hides itself when there is no game.

obs-overlay/public/overlay.html

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Score overlay</title>
<style>
  /* Transparent page: OBS composites only the score bug. Size the browser source to 640x120. */
  html, body { margin: 0; background: transparent; font-family: "Inter", "Segoe UI", Helvetica, Arial, sans-serif; }
  #bug { display: inline-flex; align-items: stretch; margin: 12px; border-radius: 10px; overflow: hidden;
         background: rgba(12, 14, 20, 0.88); color: #fff; box-shadow: 0 4px 18px rgba(0,0,0,.35); font-size: 28px; }
  .team { display: flex; align-items: center; gap: 14px; padding: 10px 18px; border-left: 6px solid #666; }
  .abbr { font-weight: 700; letter-spacing: .04em; }
  .score { font-weight: 800; font-variant-numeric: tabular-nums; min-width: 1.4em; text-align: right; }
  .detail { display: flex; align-items: center; padding: 10px 18px; font-size: 20px; color: #cfd3dc; background: rgba(255,255,255,.06); white-space: nowrap; }
  .flash { animation: flash 1.2s ease-out 2; }
  @keyframes flash { 0% { background: #f5c518; color: #000; } 100% { background: transparent; } }
  .hidden { display: none !important; }
</style>
</head>
<body>
<div id="bug" class="hidden" aria-live="polite">
  <div class="team" id="away"><span class="abbr"></span><span class="score"></span></div>
  <div class="team" id="home"><span class="abbr"></span><span class="score"></span></div>
  <div class="detail" id="detail"></div>
</div>
<script>
  // Usage: http://localhost:3030/?league=nba&team=BOS   (sport is inferred for US leagues; soccer: ?sport=soccer&league=eng.1)
  const params = new URLSearchParams(location.search);
  const query = new URLSearchParams({ league: params.get('league') || 'nba', team: params.get('team') || '' });
  if (params.get('sport')) query.set('sport', params.get('sport'));
  const REFRESH_MS = 15000; // the server caches upstream for 30 s, so this costs no extra API calls
  let last = null;

  function paint(id, side, scored) {
    const el = document.getElementById(id);
    el.querySelector('.abbr').textContent = side.abbr;
    el.querySelector('.score').textContent = side.score;
    el.style.borderLeftColor = side.color || '#666';
    if (scored) { el.classList.remove('flash'); void el.offsetWidth; el.classList.add('flash'); }
  }

  async function refresh() {
    try {
      const res = await fetch('/score?' + query);
      const g = await res.json();
      const bug = document.getElementById('bug');
      if (!res.ok || g.state === 'none') { bug.classList.add('hidden'); return; }
      const pre = g.state === 'pre';
      paint('away', { ...g.away, score: pre ? '' : g.away.score }, last && last.id === g.id && g.away.score > last.away.score);
      paint('home', { ...g.home, score: pre ? '' : g.home.score }, last && last.id === g.id && g.home.score > last.home.score);
      document.getElementById('detail').textContent = g.detail;
      bug.classList.remove('hidden');
      last = g;
    } catch (e) { /* keep showing the last score */ }
  }
  refresh();
  setInterval(refresh, REFRESH_MS);
</script>
</body>
</html>

6. Add it to OBS

Start the server, then in OBS add **Sources > Browser** with the URL below, width 640 and height 120.

Terminal

node server.js
# OBS browser source: http://127.0.0.1:3030/?league=nba&team=BOS
# soccer: http://127.0.0.1:3030/?sport=soccer&league=eng.1&team=ARS
npm test

7. Quota

Calls are only made while the overlay is open: about 360 for a 3-hour stream during a live game and 6-12 an hour before tip-off. A weekly watch-along stream is about 1,500 calls a month (Starter). Data is typically 20-30 seconds behind live play, about the same as most TV streams.

Endpoints used

  • GET /sports/{sport}/leagues/{league}/events

Full reference: docs · OpenAPI · what each endpoint returns, by league

FAQ

Can my viewers see my API key?
No. The browser source only talks to your local server, which adds the key when it calls the API.
Does it work with Streamlabs or vMix?
Yes, with any tool that has a browser source. Use the same URL.
Can I restyle it?
Yes, it is one HTML file with inline CSS. Team colours come from the API; there are no logos.

More guides