← All methods

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:

  1. Who comes back / who collapses (HT-snapshot counters, plus the 15-minute-cutoff volatility view used on the page)?
  2. How much of the match did each side actually spend leading, level, or trailing (time-in-state minutes)?
  3. When in the match do they score and concede (15-minute windows plus a dedicated 90+ stoppage bucket)?
  4. 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: matches table for HT/FT scores, plus match_events for goal-by-goal replay. HT scores are recomputed from the event stream during ingestion. Goal events come from the SDP event feed and include minute, added_time, phase, and team_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}_ht
  • points_from_{leading,level,trailing}_ht
  • wins_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_ht
  • pts_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 state
  • comeback_rate: (wins + draws from trailing) / matches trailing
  • collapse_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, or minute == 90 and added_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_leading and share_trailing are equal by construction).
  • League-wide goal counts per 15-minute bucket. Summing goals_for + goals_against across 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:

  1. SUM(matches_leading_ht) == SUM(matches_trailing_ht) — every leading team-match has a trailing opponent. Asserted by the compute script before writing.
  2. 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-position vs computed pts_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 (see playoffs.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 null rates. There is no hard minimum match filter beyond matches > 0.

What would make it better

  1. Lead-size split. Bucket leading-HT matches by whether the lead is +1 or +2 for more signal on collapse propensity.
  2. 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.
  3. Stored-key cross-check. Use computed values to audit the stored points-lost-winning-position and ponits-won-losing-position keys and either trust them going forward or document the gap.
  4. 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).