- JavaScript 47.7%
- Python 33.7%
- CSS 10.3%
- PHP 5.2%
- HTML 2.1%
- Other 1%
| game_canceled | ||
| game_completed | ||
| game_live | ||
| game_scheduled | ||
| league_helper | ||
| src | ||
| user_data | ||
| zabbix | ||
| .gitignore | ||
| add_game.php | ||
| build.sh | ||
| capture_event.py | ||
| CHANGELOG.md | ||
| collate_player_stats.py | ||
| database_migration_plan.md | ||
| deploy.sh | ||
| espn_proxy.php | ||
| game-watcher.service | ||
| game_watcher.py | ||
| get_watchlist.php | ||
| list_knockout_games.py | ||
| README.md | ||
| watched_leagues.json | ||
| world_cup_bracket.json | ||
Game Watcher
A self-hosted ESPN game monitoring dashboard. A Python service polls the ESPN public API for live game updates and writes a JSON file that a PHP/JS frontend reads to display scores.
How it works
- Configure leagues to watch in
watched_leagues.json— one entry per league, with optional team and favorite filters. - The watcher service (
game_watcher.py) runs in a loop. It is a thin entry point that delegates to modules insrc/py/(ESPN API, parsing, discovery, lifecycle, standings, output).- On startup and weekly it scans the ESPN scoreboard for each watched league and seeds
game_scheduled/with any new games in the next 14 days. - Every 120 seconds it checks all scheduled games against the ESPN API.
- When a game goes live it moves the file to
game_live/and polls it every 8 seconds. - When a game ends it moves the file to
game_completed/. - Completed games older than 10 days are automatically deleted.
- On startup and weekly it scans the ESPN scoreboard for each watched league and seeds
- Every tick the watcher writes
games_data.json— a snapshot of all games across all folders. - The frontend (
index.php) pollsgames_data.jsonevery 10 seconds and renders the scoreboard. Scheduled games more than five days out are hidden from the main (unfiltered, global) view until closer to their start time — a frontend display cutoff, separate from the backend's 14-day discovery window above. It stays well under that 14-day window's weekly safety margin (14 − 7 = 7 days) so nothing is missed. A toggle button reveals the hidden count and expands the list on demand; selecting a league filter or viewing a/game_watcher/USERpage always shows every scheduled game regardless of date. - On card click, the frontend fetches richer data on-demand from
espn_proxy.phpand displays it in an overlay.
You can still drop a game file manually into game_scheduled/ to track a one-off game outside the configured leagues.
Directory structure
game_watcher/
├── game_watcher.py # entry point — starts the service loop
├── capture_event.py # debug: capture raw ESPN payloads during a live event
├── list_knockout_games.py # utility: list knockout-stage games for bracket setup
├── watched_leagues.json # leagues, team filters, and favorites config
├── world_cup_bracket.json # maps event IDs to World Cup bracket slots
├── index.php # web frontend (built from src/ by build.sh)
├── espn_proxy.php # server-side ESPN API proxy (avoids CORS)
├── add_game.php # API endpoint: add a game to the watch list
├── get_watchlist.php # API endpoint: read a user's watch list
├── build.sh # concatenates src/ into index.php
├── deploy.sh # rsync deploy script
├── games_data.json # generated — do not edit
├── player_stats.json # generated — per-league player stat leaderboards
├── game_scheduled/ # drop new game files here (also auto-populated)
├── game_live/ # managed by the service
├── game_completed/ # managed by the service
├── game_canceled/ # managed by the service
├── user_data/ # per-user watch list JSON files
├── league_helper/ # cached ESPN API data (generated, 10-min TTL)
├── game_watcher.log # service log
├── src/
│ ├── py/ # Python modules imported by game_watcher.py
│ └── js/ # frontend JS source modules (built into index.php)
└── zabbix/ # Zabbix monitoring integration
├── game_watcher_stats.sh
├── game_watcher.conf
└── template_game_watcher.yaml
Watched leagues
watched_leagues.json controls which leagues the service automatically discovers games for, and which teams get flagged as favorites in the UI.
[
{"sport": "soccer", "league": "fifa.world", "favorite_abbrs": ["USA"]},
{"sport": "soccer", "league": "fifa.worldw", "favorite_abbrs": ["USA"]},
{"sport": "soccer", "league": "usa.nwsl", "team_abbrs": ["BAY", "LA"], "favorite_abbrs": ["BAY", "LA"]},
{"sport": "soccer", "league": "usa.1", "team_abbrs": ["SJ"], "favorite_abbrs": ["SJ"]},
{"sport": "basketball", "league": "wnba", "team_abbrs": ["GS"], "favorite_abbrs": ["GS"]},
{"sport": "baseball", "league": "mlb", "team_abbrs": ["NYM", "SF"], "favorite_abbrs": ["NYM", "SF"]},
{"sport": "football", "league": "nfl", "team_abbrs": ["SF", "NYJ"], "favorite_abbrs": ["SF", "NYJ"]},
{"sport": "racing", "league": "f1"}
]
| Field | Required | Description |
|---|---|---|
sport |
yes | ESPN sport path segment (soccer, baseball, basketball, football, racing) |
league |
yes | ESPN league path segment (mlb, nfl, wnba, usa.nwsl, fifa.world, f1, …) |
team_abbrs |
no | Only discover games involving these teams. Omit to discover all games in the league. Ignored for racing leagues — all races are always discovered. |
favorite_abbrs |
no | Teams to flag with a gold border in the UI. Can include teams not in team_abbrs. |
schedule_source |
no | Marks a league as static/manual rather than ESPN-driven: manual (schedule scraped once, status derived purely from wall-clock time — WEC, IMSA, WPBL) or stage_polled (scraped schedule, but with its own throttled live-results poll — WRC). Omit for normal ESPN-covered leagues. See the WEC/IMSA and WPBL sections below. |
Changes to watched_leagues.json take effect on the next weekly discovery run (or on service restart). No code changes required.
Adding a game manually
To track a one-off game outside the configured leagues, create a JSON file in game_scheduled/:
{
"event_id": "401696853",
"sport": "baseball",
"league": "mlb"
}
The filename can be anything ending in .json — a descriptive name like mlb_401696853.json works well. The event_id is the number at the end of the ESPN scoreboard URL for the game (e.g. espn.com/mlb/game/_/gameId/401696853).
Valid sport and league values
| League | sport |
league |
|---|---|---|
| American Football | ||
| NFL | football |
nfl |
| NCAA Football | football |
college-football |
| CFL | football |
cfl |
| Baseball | ||
| MLB | baseball |
mlb |
| WPBL | baseball |
wpbl |
| Basketball | ||
| NBA | basketball |
nba |
| WNBA | basketball |
wnba |
| NCAA Men's Basketball | basketball |
mens-college-basketball |
| NCAA Women's Basketball | basketball |
womens-college-basketball |
| Hockey | ||
| NHL | hockey |
nhl |
| Olympics — Ice Hockey Men | hockey |
olympics-mens-ice-hockey |
| Olympics — Ice Hockey Women | hockey |
olympics-womens-ice-hockey |
| US Soccer | ||
| NWSL (T1) | soccer |
usa.nwsl |
| MLS (T1) | soccer |
usa.1 |
| USL Championship (T2) | soccer |
usa.usl.1 |
| USL League One (T3) | soccer |
usa.usl.l1 |
| NWSL Challenge Cup | soccer |
usa.nwsl.cup |
| MLS Open Cup | soccer |
usa.open |
| International Football | ||
| FIFA World Cup | soccer |
fifa.world |
| FIFA Women's World Cup | soccer |
fifa.worldw |
| CONCACAF Leagues Cup | soccer |
concacaf.leagues.cup |
| CONCACAF Champions Cup | soccer |
concacaf.champions |
| Premier League | soccer |
eng.1 |
| Women's Super League | soccer |
eng.w.1 |
| La Liga | soccer |
esp.1 |
| Bundesliga | soccer |
ger.1 |
| Ligue 1 | soccer |
fra.1 |
| Serie A | soccer |
ita.1 |
| Racing | ||
| Formula 1 | racing |
f1 |
| IndyCar | racing |
irl |
| FIA WEC | racing |
wec |
| IMSA WeatherTech Championship | racing |
imsa |
WEC / IMSA — scraped schedules, no live results
ESPN doesn't carry WEC or IMSA at all (their scoreboard endpoint 400s for both), and their timing data is proprietary to Al Kamel Systems with no public API — so these two leagues work differently from every other entry in this table. Both are marked "schedule_source": "manual" in watched_leagues.json, which is what routes them away from the normal ESPN discovery/live-refresh path (see state.schedule_sources).
- Schedule only, no live updates.
src/py/scrape_racing.pyscrapes the official calendar (WEC fromfiawec.com's own schedule pages; IMSA from its Wikipedia season page, sinceimsa.comreturns HTTP 403 to any automated request) during the normal weekly discovery run. A card's Scheduled → Live → Final status is computed purely fromstart_time/duration_hoursvs. the current time — nothing is ever fetched live, soresultsstay empty forever. - WEC gets a full session list (practice/qualifying/warm-up/race) with real times, scraped from each race's page. IMSA's Wikipedia table only has a date (no time-of-day, no sessions), so its
start_timeis an approximate placeholder andsessionsis always empty. - Requires
beautifulsoup4(pip install beautifulsoup4) in addition torequests— install this on the server before deploying. WEC_SEASON_YEAR/IMSA_SEASON_YEARinsrc/py/config.pyneed bumping once a year when the new season's calendar is published.- If a season's page structure changes, the scraper logs a warning ("no races found"/"could not find schedule table") and simply discovers nothing that week rather than crashing — check
game_watcher.logif WEC/IMSA stop appearing.
WPBL — scraped schedule, no live results
ESPN doesn't carry WPBL at all — its core API lists no women's-baseball league. WPBL works like WEC/IMSA above (sport: "baseball", league: "wpbl", also marked "schedule_source": "manual"):
- Schedule only, no live updates.
src/py/scrape_wpbl.pypulls the league's own hidden calendar API (wp-json/wpbl/v1/calendar-events) during the normal weekly discovery run, keeping only games the API itself still marks"scheduled"with a confirmed matchup (already-played games and TBD placeholder slots are skipped). A card's Scheduled → Live → Final status is computed purely fromstart_time+DEFAULT_WPBL_GAME_DURATION_HOURSvs. the current time — nothing is ever fetched live, so real scores/status are never pulled. - Team abbreviations are invented, not sourced — the calendar API only gives full city names.
WPBL_TEAM_ABBRSinsrc/py/config.pyhardcodes the 4 inaugural-season teams; update it if the league expands. - Getting real live status/scores from WPBL is a possible future enhancement — the league's calendar API does carry a live "status" field and final scores, just unused for now.
Racing sessions (--session, capture_event.py only)
A racing weekend is one ESPN event ID containing multiple sessions. Use --session to pick which one to target:
| Session | --session value |
|---|---|
| Practice 1 | practice1 |
| Practice 2 | practice2 |
| Practice 3 | practice3 |
| Sprint Qualifying / Sprint Shootout | sprint-qualifying |
| Sprint Race | sprint |
| Qualifying | qualifying |
| Race (default) | race |
Game card overlays
Clicking any game card opens a full-screen overlay with richer detail. Overlays are sport-aware:
- Soccer (FIFA World Cup): goal timeline, halftime score, match stats (possession, shots, saves, fouls, cards), group standings with advancement indicators, a cross-group 3rd-place race table showing which third-place teams are currently on track to advance, and a full knockout bracket from Round of 32 through the Final with group-position labels on each slot.
- Basketball (NBA/WNBA/NCAA): points/rebounds/assists leaders for each team, technical and flagrant foul counts, a per-event Fouls list (quarter, clock, player, Technical/Flagrant tag, team), and a dynamic overtime linescore (
OT,2OT, ...) with an end-of-regulation checkpoint score for OT games. - All other sports: scoreboard, linescore (if available), venue, and broadcast info.
The overlay fetches data on-demand from espn_proxy.php when opened, so live game stats are always current. Group standings for all 12 World Cup groups are cached in league_helper/ for 10 minutes to avoid redundant API calls.
Each game card also shows all available broadcast channels, sorted by local TV → national TV → streaming.
3rd Place Race overlay — live projection
The cross-group 3rd Place Race table projects standings in real time during live group-stage games. ESPN's API updates goals-for/goals-against immediately but delays pts/W/D/L until the match ends. The frontend applies the missing pts/W/D/L delta directly so the projected standings are current throughout a match.
| Indicator | Meaning |
|---|---|
Yellow row + ● Live H–A |
Team is playing now; stats are projected from the current score |
Blue border + ○ game remaining |
Team has an unplayed group-stage game |
Green border + ↑ projected to advance |
Official 3rd-place team whose live result would move them to 1st/2nd |
Amber border + ↓ projected 4th |
Official 3rd-place team whose live result would drop them to 4th |
When a live result shifts a team's projected rank within their group, the race table shows the re-ranked team (e.g., a currently-4th team winning and projecting to 3rd) at their projected position. A footnote marks all projected rows as estimates pending the final result. All projection state is browser-only — nothing is written to games_data.json.
June 28 note (World Cup)
Starting June 28, ESPN's API transitions from group-stage to knockout format. Knockout-stage games (identifiable by having no group_name) skip the group advancement UI automatically.
About page
An "About" button in the page footer opens an overlay with contact info and an in-app changelog viewer that fetches and renders this project's CHANGELOG.md so you can see what's new without leaving the page.
ESPN proxy
espn_proxy.php is a thin server-side proxy that forwards requests to ESPN's public API endpoints. It exists to avoid CORS issues with direct browser fetches. Supported actions:
| Action | Description |
|---|---|
groups |
Division/conference hierarchy for a league (24h cache) |
teams |
Team list, optionally filtered by group/conference |
schedule |
Team schedule, with scoreboard supplement for tournament leagues |
summary |
Full event summary (box score, stats, standings) for a single game |
group_standings |
All-group standings for a tournament league (10-min cache) |
Developer utilities
capture_event.py
Polls the ESPN API for a single event and saves timestamped raw + parsed JSON snapshots to the working directory. Useful for capturing data during a live game to test or debug parsing changes.
python capture_event.py --sport soccer --league fifa.world --event-id 401696853
| Option | Description |
|---|---|
--sport, --league, --event-id |
(required) identify the event |
--dump-now |
fetch immediately without waiting for the game to go live |
--poll-live N |
poll interval in seconds while the game is live (default: 10) |
--max-hours N |
how many hours to keep capturing after the game ends (default: 6) |
--session VALUE |
racing only: which session within a multi-session weekend to target (default: race) — see the Racing sessions table above |
Works with any sport/league supported by the ESPN API.
list_knockout_games.py
Prints all knockout-stage FIFA World Cup games grouped by round (Round of 32 → Round of 16 → Quarterfinal → Semifinal → Final), with event IDs, teams, date, and venue. Use this after the bracket is set to populate world_cup_bracket.json with the correct event_id values.
collate_player_stats.py
Tallies per-player goals, assists, own-goals, and clean sheets for a soccer league, or points, assists, rebounds, and blocks for a basketball league, straight from ESPN — independent of game_completed/, so it still works for games already removed by the 10-day completed-game cleanup. Stats are kept in a separate leaderboard per league (player_stats.json's leagues key), so stats from different competitions never mix. Safe to rerun; already-processed events are skipped unless --force.
The running service already calls this hourly on its own for every watched soccer or basketball league (tournament or season-long) to keep the header's Player Stats overlay fresh — this script is a thin CLI wrapper around that same logic, kept for manual runs, backfills, and --force rebuilds.
python collate_player_stats.py --league fifa.world
python collate_player_stats.py --sport soccer --league usa.nwsl
python collate_player_stats.py --all-soccer # refresh every watched soccer league
python collate_player_stats.py --league fifa.world --force # rebuild that league from scratch
python collate_player_stats.py --sport basketball --league wnba
Writes player_stats.json (gitignored, regenerate any time by rerunning). Tournament leagues (fifa.world, fifa.worldw) always use their fixed date window; season-long leagues use a 120-day backfill on their first refresh, then a 10-day rolling window afterwards.
Running the service
sudo systemctl start game-watcher
sudo systemctl enable game-watcher
sudo systemctl status game-watcher
Logs are written to game_watcher.log and also to stdout (captured by journald).
Zabbix monitoring
The zabbix/ directory contains a Zabbix Agent2 integration that exposes service metrics:
- Game counts (live, scheduled, completed, total)
- ESPN API call and error counters
- Age of
games_data.json(staleness check) - nginx visitor hits to
/game_watcher/in the last hour
Deploy
sudo mkdir -p /etc/zabbix/scripts
sudo cp zabbix/game_watcher_stats.sh /etc/zabbix/scripts/
sudo chmod +x /etc/zabbix/scripts/game_watcher_stats.sh
sudo cp zabbix/game_watcher.conf /etc/zabbix/zabbix_agent2.d/
sudo usermod -aG adm zabbix
sudo systemctl restart zabbix-agent2
Then import zabbix/template_game_watcher.yaml into Zabbix (Configuration → Templates → Import) and link it to the host.
Development
This project is actively developed with assistance from Claude (Anthropic).