Track men's college basketball and the March Madness bracket
One Python script with three commands: today's scoreboard for the teams you follow, a live watcher that flags close finishes and finals, and the NCAA tournament grouped First Four to Championship with winners and a CSV export.
- Stack
- Python, College basketball, CSV
- Plan
- Free for the scoreboard and bracket; Starter to watch games live
- Code
- college-basketball/ (MIT)
Updated 2026-10-18. Unit tested on that date; the 2026-27 season starts November 3, so live games were not yet available. 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 college-basketball/ 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/college-basketball3. The tournament endpoint
GET /seasons/{season}/tournament returns the NCAA tournament games for a season (NIT, CBI and CIT are excluded), each annotated with tournament.region, tournament.round and tournament.firstFour. The season is the year the tournament is played: 2026 returned 67 games. Filters: round, region and firstFour. See the tournament endpoint page.
4. Group the bracket, spot close games
group_bracket orders rounds First Four, 1st Round, 2nd Round, Sweet 16, Elite 8, Final Four, Championship and puts the regionless Final Four and title game under "National". is_close flags a live game in the second half or overtime within a point margin, and idle_seconds sleeps until just before the next tip-off when nothing is live.
college-basketball/cbb_lib.py
"""Pure helpers for the men's college basketball scoreboard and bracket tracker.
No network I/O except `api_get`, so everything else is unit tested (python -m unittest).
"""
from __future__ import annotations
import json
import os
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
API_BASE = os.environ.get("RSA_API_BASE", "https://www.realtimesportsapi.com/api/v1")
LEAGUE_PATH = "/sports/basketball/leagues/mens-college-basketball"
# Round labels as the tournament endpoint returns them, in bracket order. Matching is by substring,
# case-insensitive; anything unknown sorts last.
ROUND_ORDER = ["first four", "1st round", "2nd round", "sweet 16", "elite 8", "final four", "championship"]
class ApiError(Exception):
def __init__(self, status: int, code: str, message: str, retry_after: int | None = None):
super().__init__(f"HTTP {status} {code}: {message}")
self.status, self.code, self.retry_after = status, code, retry_after
def load_dotenv(path: Path) -> None:
"""Minimal .env loader (KEY=value lines). Variables already in the environment win."""
if not path.exists():
return
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip("'\""))
def api_get(path: str, key: str, **query) -> dict:
"""GET a path under /api/v1 and return the envelope {success, data, meta}."""
qs = urllib.parse.urlencode({k: v for k, v in query.items() if v is not None})
req = urllib.request.Request(f"{API_BASE}{path}{'?' + qs if qs else ''}",
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as res:
return json.load(res)
except urllib.error.HTTPError as e:
try:
err = json.load(e).get("error", {})
except Exception: # noqa: BLE001 - non-JSON error body
err = {}
ra = e.headers.get("Retry-After")
raise ApiError(e.code, err.get("code", ""), err.get("message", "request failed"), int(ra) if ra and ra.isdigit() else None) from None
def label(team: dict | None) -> str:
team = team or {}
return team.get("abbreviation") or team.get("name") or "?"
def involves(game: dict, teams: list[str]) -> bool:
"""True when any of `teams` (abbreviations or ids, case-insensitive) plays. Empty list = every game."""
if not teams:
return True
wanted = {t.lower() for t in teams}
for side in ("homeTeam", "awayTeam"):
t = game.get(side) or {}
if str(t.get("id", "")).lower() in wanted or str(t.get("abbreviation", "")).lower() in wanted:
return True
return False
def game_line(game: dict) -> str:
"""'DUKE 71 - 68 UNC (2:14 - 2nd Half)'; upcoming games show the start time instead of 0-0."""
a, h = game.get("awayTeam") or {}, game.get("homeTeam") or {}
status = game.get("status") or {}
detail = status.get("detail") or ""
if status.get("state") == "pre":
return f"{label(a)} @ {label(h)} ({detail})"
return f"{label(a)} {a.get('score', 0)} - {h.get('score', 0)} {label(h)} ({detail})"
def is_close(game: dict, margin: int = 5) -> bool:
"""A live game in the second half (or overtime) decided by `margin` points or fewer."""
status = game.get("status") or {}
if status.get("state") != "in" or (status.get("period") or 0) < 2:
return False
a = (game.get("awayTeam") or {}).get("score", 0) or 0
h = (game.get("homeTeam") or {}).get("score", 0) or 0
return abs(a - h) <= margin
def idle_seconds(events: list[dict], now: float, default: int, cap: int = 6 * 3600) -> int:
"""Seconds to sleep when nothing is live: until ~2 min before the next start, at least 5 min,
at most `cap`. Falls back to `default` when no upcoming start is known."""
starts = []
for e in events:
if (e.get("status") or {}).get("state") != "pre" or not e.get("date"):
continue
try:
t = datetime.fromisoformat(e["date"].replace("Z", "+00:00")).timestamp()
except ValueError:
continue
if t > now:
starts.append(t)
if not starts:
return default
return int(min(max(min(starts) - now - 120, 300), cap))
def round_rank(round_label: str | None) -> int:
r = (round_label or "").lower()
for i, name in enumerate(ROUND_ORDER):
if name in r:
return i
return len(ROUND_ORDER)
def winner(game: dict) -> str | None:
"""Winning team's label for a completed game, else None."""
if not (game.get("status") or {}).get("completed"):
return None
a, h = game.get("awayTeam") or {}, game.get("homeTeam") or {}
if a.get("winner") or (a.get("score", 0) > h.get("score", 0)):
return label(a)
if h.get("winner") or (h.get("score", 0) > a.get("score", 0)):
return label(h)
return None
def group_bracket(games: list[dict]) -> list[tuple[str, list[tuple[str, list[dict]]]]]:
"""Tournament games grouped as [(round, [(region, [games by date])])], rounds in bracket order.
Final Four and championship games have no region; they are grouped under "National".
"""
rounds: dict[str, dict[str, list[dict]]] = {}
for g in games:
t = g.get("tournament") or {}
rnd = t.get("round") or "Unknown round"
region = t.get("region") or "National"
rounds.setdefault(rnd, {}).setdefault(region, []).append(g)
out = []
for rnd in sorted(rounds, key=round_rank):
regions = [(reg, sorted(gs, key=lambda x: x.get("date") or "")) for reg, gs in sorted(rounds[rnd].items())]
out.append((rnd, regions))
return out
def render_bracket(games: list[dict]) -> str:
lines = []
for rnd, regions in group_bracket(games):
lines.append(f"== {rnd} ==")
for region, gs in regions:
lines.append(f" {region}")
for g in gs:
w = winner(g)
lines.append(f" {game_line(g)}{f' -> {w}' if w else ''}")
return "\n".join(lines)
def bracket_rows(games: list[dict]) -> list[dict]:
"""Flat rows for CSV export."""
rows = []
for rnd, regions in group_bracket(games):
for region, gs in regions:
for g in gs:
a, h = g.get("awayTeam") or {}, g.get("homeTeam") or {}
rows.append({
"round": rnd, "region": region, "date": g.get("date", ""), "event_id": g.get("id", ""),
"away": label(a), "away_score": a.get("score", 0), "home": label(h), "home_score": h.get("score", 0),
"status": (g.get("status") or {}).get("detail", ""), "winner": winner(g) or "",
})
return rows
5. The commands
scoreboard is one call; watch polls live games every 60 seconds and makes one extra call per finished game for the final; bracket is one call and can write a CSV.
college-basketball/cbb.py
#!/usr/bin/env python3
"""Men's college basketball scoreboard, close-game watcher and NCAA tournament bracket.
Python 3.9+, standard library only.
python cbb.py scoreboard [--teams DUKE,UNC] today's scoreboard (one call)
python cbb.py watch [--teams ...] [--margin 5] poll live games; flag close finishes and finals
python cbb.py bracket [--season 2026] [--csv out.csv] NCAA tournament games by round and region
"""
from __future__ import annotations
import argparse
import csv
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from cbb_lib import LEAGUE_PATH, ApiError, api_get, bracket_rows, game_line, idle_seconds, involves, is_close, load_dotenv, render_bracket
HERE = Path(__file__).resolve().parent
load_dotenv(HERE / ".env")
load_dotenv(HERE.parent / ".env")
def key() -> str:
k = os.environ.get("REALTIME_SPORTS_API_KEY", "")
if not k or k == "your_api_key_here":
sys.exit("Set REALTIME_SPORTS_API_KEY (see .env.example). Free key: https://www.realtimesportsapi.com/signup")
return k
def teams_arg(s: str | None) -> list[str]:
return [t.strip() for t in (s or os.environ.get("TEAMS", "")).split(",") if t.strip()]
def scoreboard(args) -> None:
games = [g for g in api_get(f"{LEAGUE_PATH}/events", key(), limit=args.limit)["data"] if involves(g, teams_arg(args.teams))]
if not games:
print("No games on the current scoreboard" + (" for those teams." if args.teams else "."))
order = {"in": 0, "pre": 1, "post": 2}
for g in sorted(games, key=lambda g: (order.get((g.get("status") or {}).get("state"), 3), g.get("date") or "")):
print(game_line(g))
def watch(args) -> None:
k, teams = key(), teams_arg(args.teams)
seen_close: set[str] = set()
last: dict[str, dict] = {}
poll = max(30, args.poll)
print(f"Watching men's college basketball{' (' + ', '.join(teams) + ')' if teams else ''} every {poll}s", file=sys.stderr)
while True:
try:
live = [g for g in api_get(f"{LEAGUE_PATH}/events/live", k)["data"] if involves(g, teams)]
now_ids = {g["id"] for g in live}
for g in live:
if is_close(g, args.margin) and g["id"] not in seen_close:
seen_close.add(g["id"])
print(f"🔥 Close game: {game_line(g)}", flush=True)
last[g["id"]] = g
for gone in [i for i in last if i not in now_ids]:
final = api_get(f"{LEAGUE_PATH}/events/{gone}", k)["data"] # one call per finished game
print(f"🏁 Final: {game_line(final)}", flush=True)
del last[gone]
if live:
time.sleep(poll)
continue
# Nothing live: one scoreboard call, then sleep until shortly before the next tip-off.
events = [g for g in api_get(f"{LEAGUE_PATH}/events", k)["data"] if involves(g, teams)]
wait = idle_seconds(events, time.time(), default=args.idle * 60)
print(f"Nothing live; next check in {wait // 60} min", file=sys.stderr)
time.sleep(wait)
except ApiError as e:
if e.status == 401:
sys.exit(str(e))
wait = min(e.retry_after or 3600, 6 * 3600) if e.status == 429 else poll
print(f"{e}; retrying in {wait}s", file=sys.stderr)
time.sleep(wait)
except KeyboardInterrupt:
return
def bracket(args) -> None:
games = api_get(f"{LEAGUE_PATH}/seasons/{args.season}/tournament", key())["data"]
if not games:
print(f"No NCAA tournament games for {args.season} yet (Selection Sunday is in mid-March).")
return
print(render_bracket(games))
if args.csv:
rows = bracket_rows(games)
with open(args.csv, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader()
w.writerows(rows)
print(f"\nWrote {len(rows)} games to {args.csv}", file=sys.stderr)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
s = sub.add_parser("scoreboard")
s.add_argument("--teams", help="comma-separated abbreviations or team ids (default: TEAMS env)")
s.add_argument("--limit", type=int, default=50, help="max games from the scoreboard window (default 50)")
s.set_defaults(fn=scoreboard)
w = sub.add_parser("watch")
w.add_argument("--teams")
w.add_argument("--margin", type=int, default=5, help="points that count as close (default 5)")
w.add_argument("--poll", type=int, default=60, help="seconds between polls while live (min 30)")
w.add_argument("--idle", type=int, default=30, help="minutes between checks when nothing is live and no start time is known")
w.set_defaults(fn=watch)
b = sub.add_parser("bracket")
b.add_argument("--season", type=int, default=datetime.now(timezone.utc).year if datetime.now(timezone.utc).month <= 6 else datetime.now(timezone.utc).year + 1,
help="season year = the year the tournament is played (default: the coming one)")
b.add_argument("--csv", help="also write the games to this CSV file")
b.set_defaults(fn=bracket)
args = p.parse_args()
try:
args.fn(args)
except ApiError as e:
sys.exit(str(e))
if __name__ == "__main__":
main()
6. Run it
The 2026-27 season starts November 3, 2026; bracket --season 2026 works today with last season's tournament.
Terminal
python3 cbb.py bracket --season 2026 --csv bracket-2026.csv
python3 cbb.py scoreboard --teams DUKE,UNC,KU
python3 cbb.py watch --margin 4
python3 -m unittest -v7. Quota
Checking the scoreboard or bracket a few times a day fits the Free plan (125 calls a month after the first month's 1,000). Watching one team live is about 1,100 calls a month (Starter). Watching every game on a tournament day with 12 hours of games is about 750 calls, because one live-games call covers every game. For box scores of college games, the NBA box score tracker runs with LEAGUE=mens-college-basketball.
Endpoints used
GET /sports/basketball/leagues/mens-college-basketball/eventsGET /sports/basketball/leagues/mens-college-basketball/events/liveGET /sports/basketball/leagues/mens-college-basketball/seasons/{season}/tournament
Full reference: docs · OpenAPI · what each endpoint returns, by league
FAQ
- Does it include the women's tournament?
- No. The API monitors men's college basketball; the tournament endpoint returns the men's NCAA tournament.
- Are team rankings included?
- No. Events carry teams, scores, status and records; filter by the team abbreviations you care about.
- Why are box scores marked seasonal for college basketball?
- They could not be checked before the season started. The endpoint is the same one the NBA uses and is verified once games are played.