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

text
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.go
    type 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. homeassistant posts events and per-team sensors; applog is the durable JSONL event log (Goalfeed's only persistence); notify is a small set of function-pointer hooks that let targets/applog push to WebSocket clients without an import cycle back into web/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):

IntervalTask
1 minuteAsk every registered league service for active games among watched teams
1 secondRe-poll each active game, diff old vs. new score/state, fire goal/period events
1 minutetest-goals, if enabled — fire a synthetic event through the same path a real goal uses
10 minutesPublish "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.

The consequence for NFL and CFL

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.