Inspiration
You sit down forty minutes into a game. The score says 7-4 and you have no idea how it got there. Your options are to start at the live edge and spend twenty minutes lost, or scrub backwards through the game you were trying to skip.
Highlight reels do not fix this. A highlight reel is the best plays of a finished game, cut afterwards, ordered for drama. A late viewer needs something else: the plays that explain this score, in the order they happened, ending at the present moment — and available mid-game, not tomorrow.
Nobody builds that, because it has to be made on demand and it has to be right. Those two together are the interesting problem.
What it does
One button. Sixty seconds. Then you are watching live.
The app lists what is on, live games first. You press Catch Up and get a card per play that actually changed the game, in chronological order, read aloud, ending with the current score and "back to live."
Two things distinguish it from a recap someone could have pre-made:
It is built for the moment you ask. Press Catch Up in the fourth and you get the game as of the fourth. Press it again in the sixth and the recap is different, because two innings happened. Verified on device — same game, two presses: at-bat 39 top of the 5th, then at-bat 43 bottom of the 5th.
It reports what it left out. Sixty seconds does not always fit everything.
When it does not, the screen says so — 5 of 5 runs · 5 of 26 plays — and the
manifest carries limitedBy, runsExplained, runsTotal,
trimmedAfterSynthesis, narrationSource and narrationFallback. A recap
that silently drops a run is worse than one that admits it.
How we built it
The one rule: which plays mattered is computed, never asked of a model.
MLB StatsAPI publishes homeTeamWinProbabilityAdded per at-bat — how much
that play moved each team's win chance. So ranking is sorting. There is no
prompt and nothing to hallucinate.
That signal is published live, and we measured it rather than assuming it. The whole design rests on the league publishing those per-play numbers during a game rather than backfilling them at the final out, and that had only ever been confirmed on finished games. So two watchers poll a game and record one observation per tick — one on a laptop every sixty seconds, one on a scheduled Lambda every five minutes, because the laptop one kept dying and a premise confirmed by a record that stops when a lid closes is a premise still unconfirmed. Their records merge; neither is complete alone. NLCS game 849809, first pitch to final out:
| live observations | 419 |
| plays published | 1 → 70, growing 69 separate times |
| largest single jump | 2 plays |
| lag behind the live feed | 0 at-bats, 1 at the very edge |
| published after the final out | 0 |
| errors while live | none |
A feed that backfilled would show one jump from nothing to the whole game at the whistle. This is the exact opposite twice over: 69 increments of one or two plays during the game, and then nothing added afterwards — the last live tick and the first Final tick, sixty-two seconds apart, both read 70 plays. The signal was already complete when the game ended.
Each watcher reaches the same verdict on its own — 378 live observations and 67 increments from the laptop, 41 and 34 from the five-minute Lambda — so the answer does not depend on the merge, and the merged figure is quoted only because it is the complete record.
Nor does it depend on the game. The watcher ran over the next two Championship Series games as well, and all three agree:
| game | live observations | plays published | increments | lag |
|---|---|---|---|---|
| 849809 LAD at MIL | 419 | 1 → 70 | 69 | 0–1 |
| 849812 LAD at MIL | 46 | 1 → 75 | 37 | 0–1 |
| 849820 CLE at TB | 42 | 1 → 77 | 35 | 0–1 |
Three games, two series, 507 live observations, 141 separate increments, and a lag that never exceeded one at-bat in any of them.
A lag of zero is the part that matters for the product: the ranking signal sits at the live edge, so a recap built at any moment is complete up to the at-bat in progress. (One increment was −1, a play withdrawn — most likely a call overturned on review.)
The verdict is one command, ./run verdict 849809 --url <api>, and it reports
three outcomes rather than two: a watcher that has not yet seen a live tick
says so, instead of returning the same "no" it would return for a feed that
stood still all game. The record is in the repo, not in a recollection.
This is not caution for its own sake. A model asked which plays mattered answers fluently and sometimes wrongly — the game turned on a groundout in the second — and nothing downstream can detect it, because correct and incorrect output are both well-formed prose.
So the model does a smaller job: it rewords sentences the engine already wrote. On a real run it only ever subtracted — "out on a sacrifice fly to right fielder Chase DeLauter" became "out on a sacrifice fly to right", season totals and a repeated half-inning header went. Every number left was one the engine produced.
And that is enforced, not trusted. Before synthesis, every number in the model's text is matched against the engine's payload. An unsupported figure triggers one corrective retry naming the offending numbers, then falls back to the deterministic narrator with the reason recorded in the manifest. Speech is strictly downstream of the guard, so audio can never say a figure the text was not allowed to.
The packer is where the real work is. Taking the top seven plays produces
a recap where the score jumps 2-0 to 4-0 across a strikeout. So selection runs
three passes: rank under a per-half-inning cap that widens only as needed;
then cover every run, accounted per run rather than per inning, deliberately
reaching below the noise floor when a dull play drove in two; then re-check
the budget, because coverage displaces. limitedBy is decided after all three
passes — reporting the constraint the first pass hit is a convincing lie.
Sixty seconds is enforced against rendered audio. The estimator measured 14% low on real speech, which put a 63.94-second recap behind a 60-second promise on the first device run. Now the system measures what the synthesizer returned and drops the lowest-swing play until it fits, reporting the count.
Stack: Python engine, no framework. React Native on Vega OS for the TV app. Amazon Bedrock (Claude Haiku 4.5) for phrasing, Amazon Polly for speech, Lambda + API Gateway + S3 deployed. Kokoro ONNX as a local zero-credential fallback so the repo runs with no AWS account at all.
The app can be pointed at either backend from a row on the home screen, and that row is scaffolding rather than a feature. It exists so the deployed stack can be shown working on a television instead of asserted in a paragraph: press it and the list repopulates from Lambda, with the replayed game gone because the deployed stack has no replay server. A viewer has no idea what "Local server" means and no basis on which to choose, so in a real product this is a build flag and not a row — one that is currently taking a slot on the home screen away from an actual game.
Challenges
The device could not be seen or driven. The Vega CLI has no screenshot and
no key injection, so "verifying" meant watching a window. The VVD turned out
to speak the Android emulator console protocol, which has screenrecord
screenshot. Key injection there is accepted, answered OK, and delivered
nowhere — minutes went into chasing a focus bug that did not exist. event
mouse routes, so the app is driven by touch taps. That tool is what caught
the 63.94-second overrun; from across the room it looks like a correct recap.
Audio on Vega. The media player refuses http:// outright and fetch
refuses the dev server's self-signed cert that the media pipeline accepts — so
local development runs two listeners on two ports with the app rewriting URLs
between them. Deploying deletes that entirely: API Gateway terminates a real
certificate and S3 answers Range requests natively.
Four gates to a first Bedrock call, each reporting failure in a way that
looked like one of the others. The last was not quota or entitlement: the
Anthropic SDK's Bedrock client talks to a gateway entitled separately, which
returned 403 for every model while bedrock-runtime converse answered on the
same credentials in the same second. Switching to converse also took 4.8MB out
of the Lambda package.
Accomplishments
- A recap that is different every time you ask, proven on device
- The premise is measured, not assumed — 507 live observations across three Championship Series games show the ranking signal published at the live edge, never more than one at-bat behind, growing 141 separate times, with nothing backfilled at the whistle
- The guard has never been bypassed — every path is checked, including the template narrator that cannot fail it, so it cannot rot
- Honest self-reporting throughout: the manifest says which narrator spoke, why a fallback happened, what was trimmed, and how many runs went unexplained
- Sixty seconds means sixty seconds, enforced against measured audio
- Runs with no AWS account — Kokoro locally, the fixture game offline
- 16 friction log entries, each written while the problem was in front of us
What's next
Other sports. Nothing in the engine is baseball-specific beyond the name of one field, so the same arithmetic ranks a hockey or basketball game the moment a comparable per-event signal exists.
A "what did I miss" prompt that follows you into a second game, since the viewer with two games on is the one this helps most.
And lower latency on the first press. The recap is built on demand, which is the point, but the engine already knows the game advanced before anyone asks — warming the next at-bat's recap against the watcher's own tick would make the button feel instant without making it any less live.
Links
- App repo — pradeeptrain9/catchup60
- Open-source project (MIT) — pradeeptrain9/catchup-engine
- Upstream contribution — toddrob99/MLB-StatsAPI#173
Built With
- amazon-api-gateway
- amazon-bedrock
- amazon-polly
- amazon-web-services
- aws-cloudformation
- aws-lambda
- claude
- fire-tv
- httpx
- kokoro-tts
- mlb-statsapi
- python
- react-native
- vega-os


Log in or sign up for Devpost to join the conversation.