Sports data has a dirty secret: the box score you see for a game in progress is usually a lie of omission. Either the page shows nothing until the final horn, or it shows live numbers that quietly leak into the stats that feed picks, leaders and projections. We shipped live MLB and NBA coverage on the stats site last night, and the whole plan was built around refusing to make that trade.
Live rows that never touch the record books
The first decision was where live data lives. Every live poll of an in-progress game writes player and team rows into the same canonical box tables the final stats use, but stamped with a live marker on each row. Every reader that touches those tables filters those stamped rows out before it computes anything else.
The reason this matters is subtle. Most pipelines keep live snapshots in a separate table and merge at the end. That sounds clean, but then every reader has to know the merge semantics, and the day someone forgets in one query, a partial game becomes a season total. Here the invariant is simpler: a final stat row only ever overwrites a live row from the same source pipeline, live upserts never overwrite an authoritative row, and one guard test fails the build if any reader forgets the filter.
A bonus of one shared table: the box page needed zero adapter changes. The models that already read it picked up live rows the moment the writer started producing them. (That's Binderdex; the stats site itself has no public link yet.)
A 72 pixel reservation for the score bug
The second decision was interface honesty. When a live situation bar renders only after data arrives, every game page does that ugly jump: headline shifts down mid-load. Instead the bar reserves a fixed frame before anything arrives, so the page holds its shape even when a game just started and there's nothing to show yet.
Right now the bar shows what you'd expect from a broadcast: MLB gives inning, bases, outs, count and the two players involved. NBA gives quarter, clock, team fouls and the bonus. Final pages can also show a model-written recap, falling back to a template when no model ran. Everything refreshes on the same minute poll that writes the rows.
The takeaway
If you're adding live data to an existing pipeline, resist the separate-snapshots-table design. Put live and final in one table, mark live rows, filter once, and write a guard test that fails when a reader skips the filter. The small table is where correctness goes to die.