Architecture
Why Goalfeed is shaped clients → services → targets, why it polls instead of subscribing to a push feed, and what a raw score-diff costs you in exchange for that simplicity.
Three layers, one direction
clients/leagues/* services/leagues/* targets/*
(one per league; → (poll active games, diff → homeassistant (events + sensors)
raw upstream API old vs. new score/state, applog (JSONL event log)
calls + response detect goals & period notify (WebSocket hooks for
parsing) changes) the web UI)
clients/leagues/<league>/— thin HTTP clients that call each league's upstream API and unmarshal the response into league-specific structs. Nothing here understands "a goal" — it's pure transport and parsing.services/leagues/<league>/— translates client responses into Goalfeed's shared shapes (models.Game/GameState/Event) and contains the score-diff logic that decides a goal happened. Every league service implements the same interface:go — services/leagues/interface.gotype ILeagueService interface { GetLeagueName() string GetActiveGames(ret chan []models.Game) GetUpcomingGames(ret chan []models.Game) GetGamesByDate(date string, ret chan []models.Game) GetGameUpdate(game models.Game, ret chan models.GameUpdate) GetEvents(update models.GameUpdate, ret chan []models.Event) }targets/— output sinks.homeassistantposts events and per-team sensors;applogis the durable JSONL event log (Goalfeed's only persistence);notifyis a small set of function-pointer hooks that lettargets/applogpush to WebSocket clients without an import cycle back intoweb/api.
Data flows one way through this stack: upstream API → client → service → target. Nothing downstream ever calls back upstream, which is why adding a league (or a new target) is a matter of implementing one interface rather than threading changes through the whole codebase.
Why polling, not push
Every upstream source Goalfeed talks to — the NHL's own API, MLB's Stats API,
ESPN's site API for NFL, CFL's scoreboard JSON, IIHF's realtime endpoint,
olympics.com's WMR API — is an unofficial, undocumented endpoint
each league's own site or app happens to use. None of them offer a public,
contractually stable subscription/webhook mechanism a third party can register for.
Polling on a fixed interval and diffing state is the only integration surface these
APIs actually offer; it's not a design preference over a "better" push option that
was left on the table.
The main loop runs a small set of tickers (main.go,
NewTickerManager):
| Interval | Task |
|---|---|
| 1 minute | Ask every registered league service for active games among watched teams |
| 1 second | Re-poll each active game, diff old vs. new score/state, fire goal/period events |
| 1 minute | test-goals, if enabled — fire a
synthetic event through the same path a real goal uses |
| 10 minutes | Publish "upcoming game" schedule sensors to Home Assistant |
One exception: NFL additionally gets push updates from ESPN's "Fastcast" WebSocket
(services/leagues/nfl/fastcast.go) layered on top of the poll loop when
nfl.fastcast.enabled is true (the default) — this is the one place
Goalfeed does subscribe to something resembling a real-time feed, because ESPN
happens to expose one for NFL specifically.
Goal detection is a raw score-diff, and that has a cost
For every league except Olympic hockey, a league service does not consume the upstream API's actual play-by-play feed to learn what scored. It compares the watched team's last-seen score to its current score and manufactures one event per point of increase. For NHL and MLB, that's exactly correct: every goal and every run is worth one point, so one point of increase is one real scoring event.
Football scoring isn't one point per score. A touchdown-plus-conversion is 7
points, a field goal is 3, a safety is 2. Because detection is a raw score-diff,
a single touchdown-plus-conversion fires seven separate goal
events in the same tick — not one "touchdown" event. Goalfeed
doesn't know it was a touchdown at all; it only knows the score went up by 7.
An automation that plays a sound or flashes a light per event, with no debounce,
will fire seven times for one score. See
Home Assistant automations for how to design
around this (delay-then-fire, or a state-based toggle instead of a per-event
action).
This also means NHL/MLB/NFL/CFL events carry an empty Type and
Description — there's no play-by-play data behind the score-diff
to describe. Only the (untracked, work-in-progress) Olympic hockey service consumes
a real event feed and populates those fields. See the
WebSocket reference for the exact payload shape this
produces.
No database, by design
The only durable state Goalfeed keeps is the JSONL app log
(targets/applog). Active-game state lives in
targets/memoryStore, a process-local, in-memory map behind a mutex
— not Redis, not a database, not shared across processes. Everything there is
lost on restart and rebuilt from the next poll cycle, which is acceptable because the
upstream APIs are themselves the source of truth for "what game is happening right
now" — there's nothing to reconcile on restart that a fresh poll doesn't
already reconstruct.
Next
The threat model that follows from an unauthenticated API sitting on top of this — Security. Every league's exact tier — Leagues reference.