The Lineup Is Get the app
← Blog

A Live Activity that switches itself to the best game

By Vivek Tanna · Published November 10, 2026

The Lineup Is ranks every game on tonight's slate by whether it's worth watching. The feature I most wanted was a Lock Screen card that appears on its own when a good game starts, and moves a different game to the front when that one gets good. You never open the app.

That needs push-to-start, so my server can create the Live Activity while the app is suspended or closed, and update pushes to keep it current and reorder it. Getting them to work on real phones took eight rounds of fixes over about six months, and most rounds fixed the wrong thing. This is what broke, in the order I found it.

The setup

A server job keeps scores current. On each run it decides, per phone, whether a card should exist and what it shows, then sends APNs requests with apns-push-type: liveactivity. The app reports a push-to-start token (per install, to create a card) and an update token (per card, to change it).

1. The start push that iOS threw away

Symptom. Cards started fine when the app was open. With the app closed, nothing. APNs returned 200 on every start push.

Cause. Our start payload had no alert dictionary. It had been that way since the first commit. In July I built an admin harness that sends five payload variants to one phone, then watched liveactivitiesd in Console.app. The variant with an alert produced the full lifecycle: received, created, starting, started. APNs accepts a start without one, the push reaches the phone, and iOS drops it without an error.

Fix. One function builds every start payload, for the cron and the harness, and it always attaches an alert:

const payload = {
  aps: {
    timestamp: nowSec,
    event: 'start',
    'content-state': contentState,
    'attributes-type': 'LineupAttributes',
    'attributes': {},
    'input-push-token': 1,
  },
};
if (alert) {
  const alertDict = startAlert(games); // title + body, never a score in spoiler mode
  if (sound) alertDict.sound = 'default';
  payload.aps.alert = alertDict;
}

Rule. A 200 from APNs means Apple took the push, not that anything happened on the phone. Vary one field at a time and read liveactivitiesd before you believe any theory.

2. Spending the budget on nothing

Symptom. Before I found the alert bug, I blamed Apple's push-to-start budget. The logs showed every device getting a 200 on every start, and none with a running card.

Cause. The cron decided who needed a start by fingerprinting each phone's eligible games. Those games came from two sources that disagreed: an in-memory poll and a database query with a different time window. Every time the source flipped, every fingerprint changed, and every phone got another start, all day. In October I found a second leak: almost all starts went to stale device rows with no signed-in user, and a phone that never acknowledged a start got several per game. The July Console session later showed the budget wasn't what stopped the cards that day (Reduced budget for …::pushToStart to: 9), but this churn would have stopped them the moment the alert was fixed.

Fix. One time-windowed source of eligible games, and starts only when a game is added. In October the per-game retry ladder became a card state per phone: once APNs accepts a start, the server believes the card is alive and sends nothing more until it ends. A phone that never answers gets one start a night, with a small hard cap behind it.

Rule. Treat push-to-start as scarce. Send on change, not on a timer, and remember on the server that a card already exists.

3. The app ended its own cards

Symptom. With the alert fixed and the sends under control, cards still didn't stick.

Cause. Every earlier fix had been on the sending side. When I finally traced the receive side, the app was killing cards the server had just started, in two ways.

First, the worker sent a silent background push in the same tick as each start, as a "wake up and help" backup. The app woke, found the new card, saw it had no update token yet, waited five seconds, and ended it as a dead channel. A push-started card has no token for its first seconds. And since Activity.request only works in the foreground, the backup could never start a card. All it could do was kill.

Second, the stale-date trap. Our start push set stale-date 45 seconds out. With no update token yet, nothing refreshed the card, so it went .stale, and the next time the app ran, its recovery pass ended anything that wasn't .active. My September fix caught the first self-kill and missed this one.

Fix. Start pushes carry no stale-date now. Update pushes with a live game use ten minutes, and the app treats stale as a look, not an end:

/// A card on the Lock Screen. `.stale` is alive: its stale-date passed without a new
/// update, nothing more.
static func isAlive(_ state: ActivityState) -> Bool {
    state == .active || state == .stale
}

Recovery is adopt-only and keeps the newest card when duplicates exist. The ActivityKit listeners moved into didFinishLaunching, because the SwiftUI .task they lived in doesn't run on a background launch, which is when the new card's update token arrives. Any local path that ends or replaces a card now requires the app to be .active, and the server no longer sends a silent wake right after a start.

Rule. Once the server owns a card, background code must never end it. No short stale-date on a start push, and .stale is not dead.

4. Losing the push-to-start token

Symptom. In October my own phone stopped getting cards for 12 days. Game alerts still arrived.

Cause. I run Xcode builds and App Store builds on the same phone, and they shared one device record on the server. An App Store build marked the record as production while it still held a token from a sandbox build. The server sent that token to the production gateway, got 400 BadDeviceToken, and cleared it. Nothing re-sent it: on iOS 27, pushToStartTokenUpdates yielded nothing across a day of foregrounds, and our direct-read fallback only ran on older systems. Even after that fix, a device log showed iOS held push-to-start registrations for other apps and none for mine. Reinstalling the app made iOS issue one.

Fix. A BadDeviceToken now gets one retry on the other gateway before anything is cleared:

const sandbox = device.isSandbox;
let { status, reason } = await sendStart(gatewayFor(sandbox), device.token);
if (status === 400 && reason === 'BadDeviceToken') {
  const other = await sendStart(gatewayFor(!sandbox), device.token);
  if (other.status === 200) status = 200; // and record the right gateway
}

Only token-specific 400s clear a token. The app reads Activity.pushToStartToken directly on every OS, at launch and three seconds later, and logs when iOS has none, so Console tells "iOS issued nothing" from "we never asked". The token now rides with every write that records which gateway it belongs to. Keychain errors had also minted extra device ids, so about half our token records were duplicates of the same phones; the server now sends one start per phone.

Rule. Pair every token with the gateway it came from, in the same write. Don't delete a token on a 400 you haven't classified. Don't rely on the async token sequence alone; read the current value too.

5. Switching to the game that just got good

With cards starting and staying, the switch was the part people would notice. In the open app, a takeover engine lifts a game to the front when something happens: overtime, a shootout, a walk-off chance, a close finish, a comeback, an upset. With the phone locked, that engine never runs, so the card kept the app's last synced order and the game that just got good stayed where it was.

The server now runs a port of that logic, which only reorders the card. It uses the app's own tuning values, and a test reads the Swift source so the two can never drift apart.

The shape of it: the game in front has to be clearly beaten before it loses its spot, so small swings don't shuffle the card. A moment lifts a game above the normal order while it lasts and for a short tail after. A challenger needs a clear margin before it takes over, and a game that just lost the lead has to wait before it can take it back. Without those three, the card flickered between two close games every few seconds.

The harder part was deciding who's in charge. The app owns the selection, and the server backs off whenever it should: no takeover if the app synced very recently, none over a pinned game, none for a muted game, none when it can't read the user's personalization. The server does re-check the app's synced list on every push, because I once found a card showing a days-old list, and it ends the card when nothing still qualifies. To test the switch I queued a fake overtime alert against my locked phone. The card flipped in about three seconds and settled back to the score order when the moment ran out.

Rule. If the app and the server make the same decision, give them the same values, test one against the other, and decide in advance which side wins.

6. The record that left things out

One last bug wasn't in ActivityKit, but it broke what the app knew about live games. The app stays current through Supabase Realtime. Postgres leaves unchanged TOASTed columns out of a Realtime UPDATE record, and a second, smaller write to each live game didn't touch our large column of sport-specific details. The app decoded each record as a whole row, so those fields came back nil. Live football lost its possession icon and down and distance most of the time, and box scores blinked. The fix fills every key the record leaves out from the game the app already holds:

var row = record
if let held, let heldRow = try? JSONObject(held) {
    for (column, value) in heldRow where row[column] == nil {
        row[column] = value
    }
}
return try row.decode(as: Item.self, decoder: JSONDecoder())

Rule. A Realtime UPDATE is a patch, not a row. Merge it over what you have.

What I'd tell another iOS developer

  • Put an alert in every push-to-start payload.
  • Read liveactivitiesd on a real phone before naming a root cause. I fixed the sending side five times before I traced the receiving side once.
  • Send starts on change, remember that a card exists, and cap starts per night.
  • No short stale-date on a start, .stale is alive, and listeners start in didFinishLaunching.
  • Store each token with its gateway, and read pushToStartToken directly too.
  • When client and server share a rule, share the values and test them against each other.

The Lineup Is is an iPhone app that ranks every game by whether it's worth watching, sends alerts when one gets good, and keeps the best of them on your Lock Screen: thelineup.is.


Get The Lineup Is on iOS — free — or see tonight's best games ranked right now. More from the blog.

Best games tonight

  • All sports
  • Football
  • Basketball
  • Baseball
  • Hockey
  • Soccer
  • Tennis
  • Golf
  • Motorsports

Top leagues

  • NFL
  • College Football
  • NBA
  • WNBA
  • College Basketball
  • MLB
  • NHL
  • Premier League
  • La Liga
  • MLS
  • Champions League
  • ATP
  • WTA
  • PGA Tour
  • Formula 1
  • NASCAR

How we compare

  • All comparisons
  • vs ESPN
  • vs Apple Sports
  • vs theScore
  • vs Sofascore
  • vs Fixtured
  • vs FotMob
  • vs SportsRec
  • vs Nailbiter
  • vs RateGame
  • Thuuz alternative

Guides

  • Close-game alerts
  • Spoiler-free sports
  • Scores on the lock screen

Learn

  • How the score works
  • About
  • Blog
  • Press
The Lineup Is
Best games tonight · How it works · About · Feed · support@thelineup.is · Privacy · Terms and Conditions