VeePeenini, Part 5: Going Live
· Vitor Pontual · 3 min read
The last stretch before kickoff was about one feeling: when a match is on, the app should feel like everyone is in the same room. The album is a between-matches thing. The lounge is a during-the-match thing, and it needed real reasons to be open while a game is playing.
Half-drops
The core new lever is half-drops. During each half of a live match, everyone can claim one free card, but only from inside the lounge, and only while that half is actually running. Miss the window and it’s gone. Two cards a match, max. It rewards being there, live, with the group.
There was a genuine technical fork here and I took the simpler side on purpose. The “correct” way to know a match is live is to poll a live-data API for kickoffs, minutes, and half-time. That’s another external dependency, another rate limit, another thing to break at the worst possible moment. Instead I had us derive the entire live window from the one thing we already know for certain: the scheduled kickoff time, plus the clock.
// file: src/lib/game/half-drops-shared.ts
export function computeHalfDropPhase(
kickoffMs: number,
status: string,
nowMs: number,
): HalfDropPhase {
const elapsedMin = Math.floor((nowMs - kickoffMs) / 60_000);
if (status === "finished") {
return { phase: "finished", activeHalf: null, windowEndsMs: null, elapsedMin };
}
if (elapsedMin < 0) {
return { phase: "pre", activeHalf: null, windowEndsMs: null, elapsedMin };
}
if (elapsedMin < FIRST_HALF_END_MIN) {
return { phase: "first", activeHalf: 1, windowEndsMs: kickoffMs + FIRST_HALF_END_MIN * 60_000, elapsedMin };
}
if (elapsedMin < SECOND_HALF_START_MIN) {
return { phase: "halftime", activeHalf: null, windowEndsMs: null, elapsedMin };
}
// ...second half, then post
}
It’s pure math over a timestamp. No network call, no “mark this match live” button for me to forget. It fires automatically for all 104 matches. It’s less precise than a real live feed, since stoppage time and a late kickoff aren’t accounted for, and for a casual game with friends that’s exactly the right trade. At least that’s my thinking for now. I haven’t watched it hold up against a real match yet, and if kickoffs keep running late or games start spilling into long stoppage time, this is probably one of the first things I’ll have to come back and fix. For now it’s a bet that the simple version is good enough.
A lounge that knows the score
The lounge also got a live scoreboard with a goal celebration. The harder part was making everything agree on the clock. Before this, a match could be kicked off in reality but still labeled “scheduled” in one place and “live” in another, the kind of small incoherence that quietly makes an app feel untrustworthy. So I unified the match clock, the live status pill, and the drop windows to all read from the same source: the kickoff time. One clock, and everything follows it.
A match lounge: the scoreboard, the kickoff countdown, and the chat where everyone piles in once the game starts.
The realtime layer under the lounge is still the Postgres LISTEN/NOTIFY plumbing from day one, the one-line pg_notify broadcast from Part 1, which kept paying off. Presence, chat, and goal bursts all ride the same channel, still on the database I already pay for in electricity.
The game was now fair and match days felt alive. What was left was the texture of daily use, the small frictions that decide whether people keep the app open between matches. That’s Part 6.