Game State
Summary
Every other team analytic on the site collapses a season into a single
average (PPG, xPts, SQI, Elo). Game state is the opposite: it slices
each season by when goals happen and the resulting score state. The
page at /analysis/game-state answers:
- Who comes back / who collapses (HT-snapshot counters, plus the 15-minute-cutoff volatility view used on the page)?
- How much of the match did each side actually spend leading, level, or trailing (time-in-state minutes)?
- When in the match do they score and concede (15-minute windows plus a dedicated 90+ stoppage bucket)?
- How much late and stoppage-time drama are they involved in (last 10 minutes, and 90+ specifically)?
The feature is backed by team_season_game_state.
Data
- Source:
matchestable for HT/FT scores, plusmatch_eventsfor goal-by-goal replay. HT scores are recomputed from the event stream during ingestion. Goal events come from the SDP event feed and includeminute,added_time,phase, andteam_id. - Filter: regular season only (
is_playoff = 0), with both HT and FT scores present. This matches the scope of team form, splits, xPts, and SQI so cross-feature comparisons stay honest. - Time range: 2019-present. Every regular-season match has HT scores after the data-quality pass.
- Sample size: ~150 regular-season matches per 8-team season (each team plays ~28-30), split across three HT states. Per-state slices are thinner (see failure modes).
Stored-but-ignored keys
The SDP dataset stores points-lost-winning-position and
ponits-won-losing-position (typo preserved) directly. These keys
have never been validated against match results and are deliberately
ignored by this feature. All values are computed from the score
columns. A future cross-check could live in DATA_QUALITY.md.
Choices
HT state definition
From each team's perspective, for every qualifying match:
- Leading if
ht_for > ht_against - Level if
ht_for == ht_against(includes 0-0 and non-0 draws at HT) - Trailing if
ht_for < ht_against
A team's final points are 3 (win), 1 (draw), or 0 (loss). We then roll these per-match outcomes into per-team-season counters:
matches_{leading,level,trailing}_htpoints_from_{leading,level,trailing}_htwins_from_trailing_ht(full comebacks)draws_from_trailing_ht(partial comebacks)losses_from_leading_ht(collapses)
Two derived totals:
pts_dropped_winning = 3 * matches_leading_ht - points_from_leading_htpts_gained_losing = points_from_trailing_ht
Rates
The API exposes four rates, computed at read time so the stored table stays raw:
ppm_{leading,level,trailing}: points per match in that HT statecomeback_rate: (wins + draws from trailing) / matches trailingcollapse_rate: losses from leading / matches leading
Zero-denominator rates return null, never a divide-by-zero.
Time-in-state
For each match we replay the goal events in chronological order
((minute, added_time)) and accumulate, for each side, the seconds
spent leading / level / trailing. Stored per team per season as
minutes_{leading,level,trailing} and minutes_total. The match's
"end" is max(90, last_event_effective_minute) so a match with no
late goals gets the full 90 counted, and a match with a 97' goal
gets all 97 counted. Match length is per-match rather than a fixed
constant, so a side with no late drama isn't credited extra
phantom stoppage time.
API exposes share_{state} = minutes_state / minutes_total,
rounded to 4 decimals.
15-minute goal buckets
Each goal is bucketed by its effective minute
(minute + added_time):
- 0-15, 16-30, 31-45 (the last bucket includes first-half stoppage,
i.e. minute 45 with any
added_time) - 46-60, 61-75, 76-90
- 90+ (second-half stoppage only): goals where
minute > 90, orminute == 90andadded_time > 0
The 90+ bucket is split out so post-regulation drama is visually
separable from regular second-half play. Stored per team per season
as goals_for_{bucket} and goals_against_{bucket}.
Own-goal attribution: in match_events an own-goal row stores
team_id = the team of the scoring (own-goaling) player, so the
goal counts as goals_against for that team and goals_for for the
other side. Matches the convention used by team_goal_breakdown.
Late drama
Two cutoffs:
- Late = effective minute >= 80 (last 10 minutes + stoppage)
- Stoppage / 90+ = strictly the 90+ bucket above
For each cutoff and each team-match we compare a snapshot of points
the team would have taken if the match had ended at the cutoff
(pts_at_cutoff, in {0, 1, 3}) against the actual full-time points.
Per-season aggregates:
late_pts_dropped = SUM(max(0, pts_at_80 - actual_pts))late_pts_gained = SUM(max(0, actual_pts - pts_at_80))stoppage_pts_dropped = SUM(max(0, pts_at_90 - actual_pts))stoppage_pts_gained = SUM(max(0, actual_pts - pts_at_90))
Plus raw counts: late_goals_for, late_goals_against,
stoppage_goals_for, stoppage_goals_against.
This is what makes a 97th-minute equaliser visible: even if the team
was already leading at 80', the 90' snapshot still has them leading
(3 pts) while the FT result is a draw (1 pt), so
stoppage_pts_dropped picks up 2.
15-minute-cutoff volatility
The HT-state counters only fire if the lead/deficit happened to sit
at exactly 45'. Teams that lead or trail at any other point in the
match (say, a 3-minute lead before conceding twice) are invisible to
them. To capture volatility independent of when exactly the score
flipped, we take a points snapshot (pts_at_cutoff, in {0, 1, 3}) at
each 15-minute cutoff — 15', 30', 45', 60', 75', 90' — and compare
the best and worst snapshot against the full-time result:
matches_ever_leading— team held a winning position (3-pt snapshot) at any cutoff.matches_ever_trailing— team held a losing position (0-pt snapshot) at any cutoff.collapses_15m— ever-leading but finished with 0 pts.comebacks_15m— ever-trailing but finished with 3 pts.pts_dropped_15m = SUM(max(0, best_snapshot - actual_pts))— how much the team let slip from its peak.pts_gained_15m = SUM(max(0, actual_pts - worst_snapshot))— how much it clawed back from its trough.
The /analysis/game-state page uses these for the volatility
quadrants and the team-view points-gained/lost bars, with rates
computed at read time: collapse_rate_15m = collapses_15m / matches_ever_leading and comeback_rate_15m = comebacks_15m / matches_ever_trailing. Using ever-leading / ever-trailing as the
denominators keeps the rates meaningful early in a season when few
teams have led or trailed at HT specifically but most have led or
trailed at some cutoff.
League baselines
For each season the API returns:
- Sample-weighted league ppm by HT state
(
SUM(points_from_state) / SUM(matches_in_state)), used as reference lines on the PPM-by-HT-state bar chart. - League-wide share of minutes in each state (mirrors above; one
team's lead is always another's trail, so league
share_leadingandshare_trailingare equal by construction). - League-wide goal counts per 15-minute bucket. Summing
goals_for + goals_againstacross teams double-counts each goal, so the baseline halves the sum to recover the true league count.
Validation
League invariants
Two symmetry invariants every season satisfies:
SUM(matches_leading_ht) == SUM(matches_trailing_ht)— every leading team-match has a trailing opponent. Asserted by the compute script before writing.SUM(minutes_leading) == SUM(minutes_trailing)— every minute one team spent leading is a minute another spent trailing. Held on every season verified (e.g. 2024: 5037 / 5037).
Verified on shipping (v2): all eight seasons (2019-2026) satisfy both invariants. Recent counts (2024): 62 leading-HT, 62 trailing-HT, 100 level-HT; 5037 minutes leading vs 5037 minutes trailing.
Spot-checks
A sample of 2025 comebacks from the table was manually cross-checked against the match list. All counted.
Not validated
- Stored
points-lost-winning-positionvs computedpts_dropped_winning. Deliberately skipped for v1; the stored keys are flagged in the data dictionary as "stored only" and were never used downstream.
Failure modes
- Small per-state samples. An 8-team season has ~28-30 matches per team split three ways. A team with five trailing-HT matches has a ppm_trailing that moves a full point if any single match flips result. Read the raw counts alongside the rates.
- Tied HT lumped together. 0-0 at HT is grouped with 2-2 at HT as "level" in the HT-snapshot counters. Time-in-state and the 15-minute buckets sidestep this by not relying on the HT snapshot.
- No context for the lead. A team leading 3-0 at HT and a team leading 1-0 are both "leading" in the snapshot counters. Lead size isn't broken out, though time-in-state implicitly captures it (a 3-0 first half spends more minutes leading).
- Match length approximation. Time-in-state uses
max(90, last_event_minute)as the match end. A match that ran to 95' but had no late goal undercounts ~5 minutes of stoppage. This is fine for the analytical story (state didn't change in those minutes anyway) but absolute minutes are slightly low-biased. - No playoff coverage here. Playoffs are excluded to match other
analytics. Playoff-only team and player views are now available on
/analysis/playoffs(seeplayoffs.md); they don't replicate game-state slicing, which stays regular-season only. - Minimum-matches filter is implicit. Teams with zero matches
in a HT state show
nullrates. There is no hard minimum match filter beyondmatches > 0.
What would make it better
- Lead-size split. Bucket leading-HT matches by whether the lead is +1 or +2 for more signal on collapse propensity.
- Per-team match log. Click a team to see every late and stoppage drama match itemised (opponent, minute of swing, delta), making the season-level numbers explorable.
- Stored-key cross-check. Use computed values to audit the
stored
points-lost-winning-positionandponits-won-losing-positionkeys and either trust them going forward or document the gap. - Set-piece context. Pairing game state with set-piece outcomes
is deferred pending ingestion of corner events into
match_events(only goal/card/sub events are stored today).