VeePeenini Build Log

A running journal of building VeePeenini, a private World Cup sticker album and prediction game for a closed group of my friends. It came together in under three weeks before the tournament. I ran the product, the scope, and the calls that mattered; Claude 4.7 did the building and wrote most of the code. The entries run oldest first, so you can read it as one continuous timeline.
11 entries · May 24 – Jun 23, 2026
· 4 min read
VeePeenini, Part 1: The Idea and Day One
The idea showed up on a plane. I was flying back from Las Vegas on May 24 with a few empty hours and nothing to do, and I started sketching out a World Cup app for my friends. The tournament kicks off June 11, so I’d handed myself a little under three weeks. That’s exactly the kind of deadline that forces you to scope hard and skip everything that doesn’t matter.
The name is VeePeenini. It’s my handle stitched onto Panini, the company that made the sticker albums I traded with friends as a kid. That’s the whole idea, really: a private World Cup 2026 companion for my friend group of about a dozen people. Collect stickers, predict matches, trade duplicates, and watch games together in a shared lounge. No public signups, no growth funnel, no ads. Just us.
I own the idea, the scope, the priorities, the UX calls, and the launch decisions. Claude 4.7 did the actual building and wrote most of the code, fast. I directed it, reviewed the architecture, pushed back on choices I didn’t like, and made the calls that had real consequences. I can work through this stuff myself, just a lot slower, so I knew what I was signing off on. The split was simple: it built, I decided what we were building and why, and I stayed paranoid about the things that would quietly go wrong.
The product, before any code
Before writing anything, I fixed the four things the app had to do well:
- Collect. Open packs, get player and team stickers, paste them into an album. This is the nostalgia hook, so it had to feel good, not like filling out a form.
- Predict. Call the scores and the bracket. This is the competition that keeps people coming back match after match.
- Trade. Swap your duplicates with friends. A sticker album is only fun if you can hunt the ones you’re missing.
- Watch together. A live lounge during matches so the group is in the same room even when we’re scattered across cities.
Everything else was negotiable. Those four were not.
Where it ended up: the dashboard, with the daily pack claim, the next-match countdown, and your rank at a glance.
The stack, and the one call that mattered
The build is Next.js 16 on the App Router, Tailwind 4, Drizzle for the database layer, and Postgres 17. Auth through NextAuth. It runs on a small server at home behind a Cloudflare Tunnel, so my friends get a normal https:// link and I get to keep the whole thing on hardware I own.
The decision I cared most about on day one was the live lounge. The easy path is to reach for a hosted realtime service. I didn’t want a third-party dependency, a per-message bill, or my friends’ chat sitting on someone else’s servers for a six-week project. Postgres can already do this: LISTEN/NOTIFY to broadcast events, server-sent events to push them to the browser. So that’s what we built. No Supabase, no Pusher. It’s a slightly more hands-on path, and I signed off on it knowing that. The entire presence broadcast is one line of SQL:
// file: src/app/api/lounge/[matchId]/stream/route.ts
async function broadcastPresence(channel: string, viewers: number) {
const payload = JSON.stringify({ type: "presence", viewers, at: new Date().toISOString() });
await db.execute(sql`SELECT pg_notify(${channel}, ${payload})`);
}
Zero to deployed in fourteen commits
Day one was one long session, phased so each piece stood on its own before the next went in:
- Foundations and the database schema for teams and players.
- The pack drop, the 3D card flip, and a static roster to test against.
- Fixtures, the bracket, predictions, and the grading logic that scores them.
- The trading post: create, accept, decline, cancel, with an atomic settle so two people can’t both walk away with the same card.
- An admin panel so I could enter match results and manage the group.
- The live lounge, running on the
LISTEN/NOTIFYplumbing. - Polish: Giphy in the lounge, PWA icons so it installs on a phone, a PDF export of the album.
- Production deploy to the home server.
By the end of the day the thing was live on the internet, behind a login, with the four pillars all present in rough form. None of it was pretty yet. The album was a plain grid, the stickers were placeholders, and I was the only user. But the shape was real, and the shape is the hard part to get right.
The next two weeks were about closing the distance between “it runs” and “I’d actually show this to my friends.” That’s where it got interesting, and where most of the real product work happened. More on that in Part 2.
· 5 min read
VeePeenini, Part 2: Making It Real
By the end of day one VeePeenini ran. That is a very different thing from being ready to put in front of people I actually like. The next two days were about the unglamorous distance between those two states, and most of it was product work, not new features.
The auth reckoning
My first auth choice was magic links. Click a link in your email, you’re in. Clean, no passwords to manage. Except it broke the moment the app went behind a Cloudflare Tunnel. The verification token is tied to the host that issued it, and the proxied host didn’t match, so every link came back invalid. I spent a little time poking at it and then made a product call: this is a closed group of about a dozen people I know personally. I don’t need self-serve signup. I’ll provision the accounts myself and hand out passwords.
So we ripped out magic links and switched to admin-provisioned passwords with a forced reset on first login. That surfaced a couple of NextAuth details I had to get right. /api/auth/* is its catch-all, so our own account endpoints had to live elsewhere, and the “you must reset your password” redirect had to allow the very endpoint that lets you change it. Small stuff, but the kind of thing that quietly locks everyone out if you miss it. The lesson I keep relearning: pick the auth model that fits the actual users, not the one that looks cleanest in a tutorial.
Making the album feel like an album
The day-one album was a grid. Functional, lifeless. A Panini album is a physical, tactile thing, and that feeling was the whole reason to build this, so it got a real rebuild.
The final shape is 48 teams, 28 stickers each: 26 players, the coach, and the team crest, for 1344 cards total. One country per page, the crest anchored to the country’s banner art like a masthead. Each card wears a frame that tells you its rarity at a glance.
A country page: the flag-banner masthead with the team crest, the coach, and the start of the Panini spread in rarity frames.
The part I cared most about is what happens when you place a sticker. It is not a state change, it is a little ceremony. You open a pack and the new cards drop into a Sticker Pile so you can see exactly what you just got. You tap one into its slot, the card flips, a gold “Pasted” stamp lands at a slight angle, and it settles into a hand-placed tilt like you pressed it in yourself. There was a genuinely fiddly bit here that I had to reason through with Claude: while the card is mid-flip it is already rotated, so applying the tilt in the wrong order mirrors it and the whole thing looks broken. The order of the rotations matters, rotateZ before rotateY, or the tilt flips with the card:
// file: src/app/album/album-cell.tsx
transform:
stage === "stamped" || stage === "done"
? `rotateZ(${finalTilt}deg) rotateY(180deg)`
: flipping
? "rotateZ(0deg) rotateY(180deg)"
: "rotateZ(0deg) rotateY(0deg)",
That is the sort of detail nobody notices when it is right and everyone feels when it is wrong.
Mila
A leaderboard is just a table until it has an opinion. So the app got a mascot, Mila, with a voice that runs across the whole product. She comments on the dashboard, she talks trash when you’re sitting at the bottom of the rankings, and she has a few different registers so she’s not repeating herself. She is also an NPC competitor, picking matches alongside everyone else. That is the difference between an app you check and one your friends quote back to each other in the group chat.
Mila, the mascot. She has opinions about your league position, and she is not shy about them.
Thinking like an owner about other people’s stuff
This is where my head shifted from “ship it” to “do not ever lose anyone’s cards.” Once my friends start collecting, their albums are theirs, and a data-loss bug isn’t an inconvenience, it’s a betrayal. So before launch I had us build a fair amount of boring infrastructure:
- Nightly backups to more than one destination.
- A real migration registry instead of force-pushing schema changes, so the database changes in tracked, reversible steps.
- A deploy guard that snapshots every table’s row counts before and after a deploy and aborts the whole thing if any user’s card count shrank. Reference data is allowed to change; people’s collections are not.
- An append-only audit log on every sticker change, so if anyone ever says “I lost my Messi,” I can prove what actually happened.
- A read-only database user for late-night poking, so a stray query can’t delete anything.
The deploy guard is my favorite of these, because it’s the one that turns “be careful” into something the machine enforces. Every deploy diffs row counts before and after. Reference data is allowed to move; if anyone’s collection shrinks, the whole deploy aborts:
# file: scripts/db-snapshot.sh (--diff mode)
if [[ "${after_val}" -lt "${before_val}" ]]; then
case "${key}" in
teams|players|allowed_emails|match_fixtures)
echo " ~ ${key}: ${before_val} → ${after_val} (reference data, ok)" ;;
*)
echo " ✗ ${key}: ${before_val} → ${after_val} (USER DATA SHRANK)" >&2
EXIT=1 ;;
esac
fi
A failed diff exits non-zero and the deploy stops. I’d rather a deploy refuse to finish than quietly take someone’s cards.
The best catch was incidental. The home server was running a nightly Docker cleanup that included wiping unused volumes. During a deploy, when the database container is briefly detached, that cleanup could have decided the Postgres volume was unused and deleted it. The whole game, gone, on a timer, with nobody touching anything. We pulled that flag. I think about that one a lot.
Launch
With the album feeling right, Mila talking, and the backups running, I sent invites to the first few friends. The app went from one user (me) to a real, if small, group, with Mila in the mix as the NPC nobody wants to lose to.
Launching is not the end of the work. It’s the start of a different kind. That’s Part 3.
· 5 min read
VeePeenini, Part 3: Launch and the Long Tail of Data
The day after launch, one of my friends sent me a message: a group on the bracket was showing the wrong teams. Not a crash, not an error page. Just quietly, confidently wrong. This is exactly the kind of bug you only catch once real people are looking, because they know the tournament better than your test data does.
The bug that was really two files arguing
The group letters were wrong on the bracket but right everywhere the actual fixtures were involved. When I dug in with Claude, the cause was almost funny: two different files in the codebase each held their own hardcoded copy of which team is in which group, and over a week of changes they had drifted apart. Nine of the twelve groups had at least one wrong assignment in one of the files. The database, the actual source of truth, had been correct the whole time.
I could have just fixed the letters and moved on. Instead I treated it as a class of bug, not an instance. The real problem was that the same list lived in two places, which guarantees they eventually disagree. So we restructured it so the group is derived from one source, and the second copy was deleted entirely. Now if the two ever fall out of sync, the app refuses to start instead of silently lying. The header comment on the generated roster file is the whole story:
// file: src/lib/game/roster.ts
// Group letters are NOT stored here anymore — they are derived from
// src/lib/teams/meta.ts (the single source-of-truth for team identity).
// Previously the group letter was duplicated between meta.ts and this
// file, and the two drifted (9 of 12 groups showed wrong teams on the
// bracket). The transformation block below merges TEAMS.group into each
// team at module load and throws loudly if any team is missing from
// meta.ts. Result: drift is impossible.
The rule I took away, and keep taping to the wall: when two files hold the same list, derive, don’t duplicate.
Rosters that won’t sit still
Building a World Cup app three weeks before a World Cup means the data is actively wrong and getting righter on someone else’s schedule. Federations announce their final 26-man squads right up to the deadline, so on day one a lot of my “players” were placeholders waiting on real names.
As each squad dropped, I replaced placeholders with real players. The trap here is subtle and I got bitten by it: the seed file that sets up the database would happily overwrite a fresh roster on the next deploy, because it thinks it knows the truth. So every roster change had to land in two places at once, the live database and the seed source, or the next deploy would quietly undo my work. It took two big waves to get every team to a real, final squad, with the elite players tagged so they show up as the rare cards everyone chases.
Chasing 100% photo coverage
A sticker without a face is sad. Getting a photo for all 1296 player and coach cards was its own small project, and it ran on a chain of fallbacks: pull from Wikipedia first, then Wikimedia Commons, then a football data API, then Transfermarkt for the deep tail.
The interesting failure was the matching. An early version matched on too little, so when it searched for a name it would grab the wrong player who happened to share a first letter, an “Adrián” standing in for an “Alejandro.” For a sticker album that is a real bug, because the whole point is that the face matches the name. So we tightened the scoring to reward exact and prefix matches and reject the first-letter-only collisions:
# file: scripts/scrape-api-football-misses.py
if target_first == api_first_token:
score += 20 # exact first-name match
elif api_first_token.startswith(target_first) or target_first.startswith(api_first_token):
score += 15 # one is a prefix of the other ("m" vs "mohamed")
elif len(target_first) >= 4 and len(api_first_token) >= 4 and target_first[:4] == api_first_token[:4]:
score += 8 # first 4 letters match (diacritic-stripped near-matches)
else:
# First letter could match but full name is a different family -- reject.
# This is the collision we keep hitting (Adrián/Alejandro, Ferry/Frenkie).
return -1
I hand-fetched the last stubborn dozen who simply have no decent photo anywhere public. The album landed at full coverage: every player and coach has a photo, and every crest its flag.
The album index: 48 countries, each its own Panini page to fill.
The part nobody sees
A surprising amount of this stretch was me staring at a card frame and deciding a rarity label sat two pixels too high. There’s a run of commits that are nothing but nudging a tier label down one percentage point, then back up half a point, then a touch left to center it in the frame. It is deeply unglamorous, and it is exactly the kind of thing that separates something made with care from something that looks generated. That polish is part of the job, not beneath it.
By the end of this stretch the data was honest, the faces were real, and the frames sat right. Next the mood changed entirely, because the tournament was about to actually start. That’s Part 4.
· 4 min read
VeePeenini, Part 4: Correctness Before Kickoff
With the first match a few days out, my priorities flipped. Up to now the question had been “what should this do next.” Now it was “can I promise this won’t lose anyone’s cards or mis-grade a prediction.” Those are different jobs. The first is additive and fun. The second is paranoid and quiet, and it’s the one that decides whether a group of friends still trusts the app in week three.
The bug that bit me twice
There is a specific trap in how Drizzle, our database layer, handles arrays. If you write a query that asks “is this id in this list” using a JavaScript array directly, Drizzle splats the array into separate placeholders, and Postgres ends up reading it as a single malformed value and throws. I’d hit this early, understood it, and fixed it by building an explicit list instead:
// file: src/lib/game/trade-executor.ts
// Cannot pass a raw JS array to `= ANY(${arr})` because Drizzle
// serializes it as a quoted string ("983"), which Postgres rejects
// as "malformed array literal".
const stickerParams = sql.join(stickerIds.map((n) => sql`${n}`), sql`, `);
const rows = await tx.execute(sql`
SELECT sticker_id, quantity FROM user_stickers
WHERE user_id = ${userId}
AND sticker_id IN (${stickerParams})
AND is_pasted = false
FOR UPDATE
`);
Then, during a pass to harden the trading code, I reintroduced the exact same pattern in a new spot. The production logs lit up with “malformed array literal” and every single trade acceptance started failing. People couldn’t complete trades at all. The fix was the same as before, but the lesson was bigger: I cannot trust my intuition on this one binding, because it reads as obviously correct and is obviously wrong. So now, before any change touches trade or admin code, I grep the whole codebase for the pattern. The cheapest insurance is treating a recurring bug as a standing rule, not a one-time fix.
Making the game fair
The core of VeePeenini is the prediction game, and a prediction game only works if nobody can change their pick after the result is knowable. So predictions now lock per match, each one at its own kickoff time, not in big batches. And grading was built so I can re-run it safely if I enter a result wrong and correct it, without double-counting or corrupting anyone’s score. Boring to build, essential to trust.
Trades got a fairness fix too. An offer locks the cards it involves so they can’t be traded twice, but that means a forgotten offer could keep someone’s cards hostage forever. So stale trades now auto-expire after a day and release whatever they were holding. Nobody’s collection should get stuck behind an offer the other person never answered.
Beat the Oracle
One feature in this stretch was both a product idea and a data integration I had to keep honest. I pulled bookmaker odds for the matches and drew a win-probability bar on each one, so you can see what the market thinks before you make your call. Then I had Mila, the mascot, pick according to those odds. So she isn’t just trash-talking, she’s playing the market line, and beating her means beating the bookmakers:
// file: src/lib/odds/devig.ts
export function favorite(prob: ThreeWayProb): "home" | "away" {
return prob.home >= prob.away ? "home" : "away";
}
She backs whichever side is more likely to win outright and never predicts a draw. It reframes every prediction as a little bet against the consensus, which is a lot more interesting than picking blind.
The table, with Mila in the field as the Oracle. Beating her means beating the bookmakers, not just tying the match.
The alerts that were never firing
The quietest and most unsettling find: I had alerts set up to warn me if something went wrong with the server, and they had never once fired. Not because nothing went wrong, but because the background timer that runs them couldn’t read the secret it needed to send the message. It had been silently dead since I set it up. A monitoring system that can’t alert is worse than none, because you think you’re covered. We fixed how it reads its secrets, along with adding real tests, error reporting, and a sentinel that warns me if the database schema drifts from what the code expects.
None of this is the work you brag about. There’s no screenshot that captures “trades no longer fail” or “predictions lock correctly.” But this is the work that lets a product survive contact with real, competitive use. With the game now fair and the safety nets real, the last job was to make match days actually feel alive. That’s Part 5.
· 3 min read
VeePeenini, Part 5: Going Live
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.
· 5 min read
VeePeenini, Part 6: Quality of Life
With the game fair and the lounge live, the rest of the work was quality of life. None of it is headline material. All of it decides whether the app earns a spot on the home screen or gets swiped off it. This is the stuff I find most satisfying to get right, because it’s invisible when it works and grating when it doesn’t.
Push notifications and an app-icon badge
A trade is a social act. Someone makes you an offer and then waits. If you don’t know it’s there, the whole loop stalls. So the app got web push: you get pinged when someone offers, counters, accepts, or declines a trade. And the installed app icon carries a number badge, the same red dot you know from every other app, that works even while the app is closed.
The service worker handles the push and the badge together:
// file: public/sw.js
event.waitUntil(
(async () => {
await self.registration.showNotification(title, {
body: data.body || "",
icon: "/icons/icon-192.png",
badge: "/icons/icon-192.png",
tag: data.tag,
renotify: true,
data: { url },
});
if (typeof data.badgeCount === "number" && "setAppBadge" in self.navigator) {
try {
if (data.badgeCount > 0) {
await self.navigator.setAppBadge(data.badgeCount);
} else {
await self.navigator.clearAppBadge();
}
} catch {
// Badging unsupported or PWA not installed — non-fatal.
}
}
})(),
);
Two product decisions hid inside this. First, the badge counts both incoming trades and stickers you’ve won but haven’t placed yet, not unopened packs. Packs pile up and would keep the badge permanently lit, which trains people to ignore it. Incoming trades and unplaced stickers are things a person actually needs to act on. Second, iOS only allows push for a PWA that’s been added to the home screen, so the notifications toggle detects a plain browser tab and shows install instructions instead of a dead switch that silently does nothing. A toggle that lies is worse than no toggle.
The send path had its own small war story. The home server intermittently fails to resolve the push gateway’s DNS, so a single send can fail for reasons that have nothing to do with the subscription. Dead subscriptions get pruned forever; transport errors get one retry.
// file: src/lib/push.ts
if (status === 404 || status === 410) {
// Subscription is gone for good — prune, never retry.
dead.push(r.endpoint);
return;
}
// No HTTP status === transport/DNS error (the container hits
// occasional EAI_AGAIN). Retry once before giving up on this push.
if (status === undefined) {
try {
await webpush.sendNotification(subscription, body);
sent += 1;
return;
} catch (retryErr) {
console.error("[push] send failed (after retry):", (retryErr as Error).message);
return;
}
}
Trade notes
Trades got a one-line note you can attach to an offer or a reply. “Pleasure doing business.” “You’re robbing me here.” It’s a tiny thing, and it’s exactly the kind of human texture that spills the group chat into the app. There’s a single place that cleans every note, so there’s only one rule for what a message can be, enforced once instead of in four different routes:
// file: src/lib/game/trade-executor.ts
export function normalizeTradeMessage(
message: string | null | undefined,
): string | null {
if (message == null) return null;
const collapsed = message.replace(/\s+/g, " ").trim();
if (collapsed.length === 0) return null;
return collapsed.slice(0, TRADE_MESSAGE_MAX);
}
The trade marketplace, defaulting to “cards you need” so you aren’t scrolling hundreds of names trying to spot the ones that matter.
Knowing who actually showed up
The app could tell me someone’s streak, how many days in a row they’d claimed a pack, but it couldn’t tell me which exact days they’d shown up. A counter isn’t a history. So I added an append-only attendance log, written from the auth session callback. The important property is that it can never get in the way of logging in: it dedupes in memory so it writes at most once per person per day, and if the write fails it swallows the error and lets a later request retry.
// file: src/lib/game/daily-login.ts
if (seenUsers.has(userId)) return;
seenUsers.add(userId);
try {
await db.execute(sql`
INSERT INTO daily_logins (user_id, login_date)
VALUES (${userId}, ${today}::date)
ON CONFLICT (user_id, login_date) DO NOTHING
`);
} catch (e) {
seenUsers.delete(userId); // let a later request retry
console.error("recordDailyLogin failed:", e);
}
It’s the kind of data you always wish you’d started keeping on day one, so I started keeping it. Forward from here it’s exact; the past is gone.
A deploy that can move
Last thing. I have a move coming up, which means the app will need to shut down on the server it runs on now and come back up at a second location on different hardware. Rather than treat that as a future fire drill, I had the deploy taught to target more than one host, so relocating the whole thing is a config change instead of a rebuild. It’s cheap to do calmly now and miserable to do under pressure later. A lot of good infrastructure work is just paying small costs early so you don’t pay large ones at the worst time.
That’s the build, more or less caught up to today. The last post is the one I can’t finish yet, because it hasn’t happened: the tournament itself. Part 7 is what I’m watching for.
· 4 min read
VeePeenini, Part 7: What's Next
Everything in the first six parts was me building against a deadline. This part is the opposite: the build is done, and now I wait for the only test that matters. The World Cup starts on June 11. Until a real match kicks off with my friends all logged in at once, a lot of what I built is a well-reasoned guess.
Here’s what I’m actually watching for.
Ripping open a pack. The build is done; the tournament is the test.
The things I couldn’t fully test
- The lounge under real load. During a group-stage match I expect everyone online at the same time, reacting to the same goal in the same second. The realtime layer held up fine with me and a script. It has never had a dozen real people leaning on it during a 1-0 in the 89th minute. That’s the moment it either feels magic or falls over, and I won’t know which until it happens.
- Half-drops on a genuinely live match. The drop windows are derived from kickoff time, which I tested by backdating fixtures. But a real match has a late kickoff, a long VAR check, eight minutes of stoppage. I’m fairly sure the windows are forgiving enough, and “fairly sure” is the honest status. The first live match is the real unit test.
- Knockout bracket resolution. The group stage is 72 matches of relatively simple grading. The knockouts introduce progression, tiebreakers, and a bracket that has to redraw itself as results land. I’ve reasoned through it and seeded test data, but the real bracket filling in live, with people’s predictions riding on it, is its own kind of pressure.
The things I know are coming
- Friends who fall behind. In any collection game, a few people pull ahead early and a few drift. There’s already a dormant mechanism for this: a quiet boost that biases pack drops toward cards a lagging player is missing, paired with an overt nudge from Mila. I left it switched off at launch on purpose. I’ll turn it on around the tournament’s halfway mark, once I can see from real data who’s actually drifting, instead of guessing.
- Squads that keep changing. Even with final 26-man rosters locked, injuries and late replacements happen during a tournament. The photo and roster pipeline from Part 3 is built to absorb that, but every change is a small live operation against production, and those are exactly the moments to stay careful.
- The move. I have a move coming up that will mean shutting down the server VeePeenini runs on now and bringing it back up at a second location. The multi-host deploy is built for exactly this, but actually exercising it, picking the app up and setting it down on different hardware while real people are mid-collection, is still ahead of me.
What building this way taught me
Claude 4.7 wrote most of the code, and it moved faster than I could alone. My job was the part that doesn’t compile. Deciding what this should be. Scoping it down to something I could actually finish in under three weeks. Making the calls that had consequences, like deriving live windows from a timestamp instead of polling an API, or keeping the realtime layer on Postgres instead of renting it. Reviewing the architecture. And staying paranoid about the specific things that would quietly betray my friends’ trust.
That last one is worth sitting on. The bugs that mattered most weren’t found by writing code faster. The Drizzle binding that failed every trade. The nightly cleanup that would have wiped the database. Two files quietly disagreeing about which teams were in which group. Alerts that had never once fired. None of those came from velocity. They came from an owner asking, over and over, “what could silently go wrong here, and who gets hurt when it does.”
So the headline isn’t that an AI built a soccer app. It’s that this working model let one person carry a real product, with real users and real stakes, from an idea on a plane to a launch, in under three weeks, without losing the thread of what actually mattered. The code was the fast part. Knowing which features, in what order, and what to leave out, was the job. That part was mine.
The app is live. My friends are arguing in the lounge and hunting the cards they’re missing. The tournament starts in a week, and then I find out which of my guesses were right. I’ll write that part when I know.
· 3 min read
VeePeenini, Part 8: Moving the Server Without Losing the Game
For a few weeks the app just worked. My friends were opening packs every morning, filling their albums, arguing in the lounge, making predictions before kickoff. The build was behind me and the fun was the point. Then a logistics problem landed on top of all of it: the whole thing runs on a server in my home, and a move was coming that meant shutting that server down and bringing the app back up at a second location.
The tournament does not pause for my logistics. Points were being awarded every day, albums were filling in, trades were flying back and forth. So the task wasn’t “redeploy the app somewhere else.” It was “relocate a database that real people are actively using, mid-tournament, without losing a single thing they’ve earned.”
That last part is the whole job. In a game built for a closed group of friends, the fastest way to kill it is to lose someone’s points, or drop a sticker they traded for fairly. The scores are made up and the stakes are imaginary, and that is exactly why the trust is not optional. Lose one person’s progress once and the group stops believing the standings, and then there’s no game.
The rule that fell out of it
The first thing I made myself sit with, before any plan, was the uncomfortable shape of the problem: the two locations have completely separate databases. There is no shared disk between them and no live replication. If both boxes are ever serving at the same time, my friends’ actions land in two different databases that can never be merged back together. A trade recorded in one, a pack opened in the other, and the histories fork forever.
That is the failure mode that ends the project, and naming it early gave me the one rule everything else had to obey: exactly one live writer, ever. At every moment, one box and only one box is allowed to accept changes. The entire plan became a careful dance around keeping that true through the handoff.
The pieces in play
A couple of facts about the setup shaped everything that came after.
The app sits behind a Cloudflare Tunnel, so the public address can be pointed at one box or the other without me touching much by hand. That is the lever I’d eventually pull to switch which server the world talks to.
And the second location is a small, always-on machine I keep running elsewhere, which is a different kind of computer entirely. The home server is an ordinary x86 box; the second one is an ARM machine. That difference sounds like trivia and it is not, and it was the first thing to bite us.
I’d done plenty of ordinary deploys. I had never done a live cutover of a database that people were in the middle of using. This was going to need a real plan, and the plan went wrong in several instructive ways before it went right. That’s Part 9.
· 5 min read
VeePeenini, Part 9: The Plan, and All the Ways It Was Wrong
On paper the plan was one sentence: copy the database from the old box to the new one, point the front door at the new box, done. Everything that mattered lived in the gap between that sentence and a version I would actually trust with my friends’ data. Closing that gap is most of what this entry is.
The boring problems that were actually the dangerous ones
The tunnel wasn’t only the app’s. The Cloudflare Tunnel in front of the game also carried a couple of my other personal services. “Just move the tunnel to the new box” would have quietly knocked those over too. So the call was to give the app its own dedicated tunnel, and at switch time repoint only the app’s address to it, leaving everything else untouched.
The new box is a different computer. Because the second machine is ARM and the home server is x86, you can’t copy the built app across; the image has to be rebuilt for the new chip. Easy once you know it, a confusing failure if you don’t.
A missing key, caught by being boring. When we diffed the new box’s configuration against the old one, the keys that sign push notifications were absent. Flip without noticing and notifications would have died silently, and “silently” is the worst word that can appear in that sentence. Nobody would have reported it; it would just slowly stop working. Diffing two config files line by line is dull work. Dull work is what catches this.
The scoring jobs, not just the app. The app is not only a website. A set of background jobs do the real game work. One freezes the betting odds the instant a match kicks off, because the scoring math depends on those frozen odds. Another grades the match when it ends and hands out the points and the loot packs. Those jobs had to move too, and be running on the new box before the next kickoff, or points would come out wrong with no error anywhere to warn me. Moving “the app” was the easy eighty percent. These jobs were the dangerous twenty.
A writer that bypassed the app. One nightly job writes standings history straight into the database instead of going through the app. That punched a hole in my first instinct, which was “just stop the app and writes stop.” Not quite. That job had to be stopped by hand as well, or it could scribble a new row into the old database after I’d already taken my copy.
How do I prove nothing was lost?
Not “believe,” prove. Row counts are a trap here: two tables can have the same number of rows while the contents are quietly different. So we verified with content checksums, an md5 fingerprint of the actual data in the tables that hold anything a player cares about, the points, the stickers, the trades.
-- a single fingerprint of every prediction's points and graded state
md5(string_agg(id || ':' || points_awarded || ':' || is_graded, ',' ORDER BY id))
Compute that on both boxes. If the fingerprints match, the data is identical down to the byte, not just the count. That one idea is what later let me tell my friends “nothing was lost” and actually mean it.
The hardest call was when, not how
The technical steps were nearly settled. The genuinely hard decision was timing.
My first idea was to do it at halftime. The live card-drop windows I built back in Part 5 mean nothing is claimable during the break, so halftime looked like a natural dead zone. It wasn’t. Two things killed it. At halftime the match isn’t graded yet, so the new box would have had to settle its first real match in production with everyone watching. And halftime is exactly when people pick their phones back up, since nothing is happening on the pitch. The quiet was an illusion.
So we landed on the gap between games, after a match had completely finished and its points were already in. Then the new box inherits a clean, settled, verified state, and the only thing ahead of it is the next game.
Turning “please don’t” into “can’t”
The decision that made the whole thing safe rather than merely likely: take the front door down. Instead of asking everyone to please not touch anything for a few minutes and hoping they comply, I had us stop the app entirely during the swap. It’s ugly, because the app is briefly unreachable. But it converts “please don’t” into “can’t,” for every person and every kind of action at once. That short blackout is the only window in which both databases are provably identical, which is the entire reason it exists. The downtime is not a flaw in the plan. It is the plan.
Then we rehearsed it for real: dump the database to a file, restore it onto the new box, and time the whole thing. It came back in about three seconds, and the restore didn’t even require stopping the new box’s app first. Knowing that before the live attempt is the whole difference between confidence and hope.
The plan was finally one I trusted. Running it is Part 10.
· 4 min read
VeePeenini, Part 10: Ninety Seconds of Downtime
The plan from Part 9 was good. Running it live, while my friends were mid-tournament, is a different feeling. Here is how it actually went, step by step, because the calm version is the one worth remembering.
Wait for “finished,” not for the whistle
First I waited for a match to be genuinely done, which is not the same as the final whistle. The app grades a match a minute or two after it ends: it settles every prediction, awards the points, and drops the loot packs. If I had taken my copy of the database in that window, I’d have captured a half-graded match and lost the points that landed a moment later.
So I watched the database directly until every prediction for that game had flipped to graded and the running totals stopped moving. Whistle is not finished. Graded and stable is finished. Only then did I move.
Take the front door down
I asked everyone to hold for a few minutes, then I took the front door down: stopped the app, and separately stopped the one nightly job that writes straight to the database. With both of those off, nothing on the old box could change. The blackout had started, and the clock was running, and that pressure is exactly why all the rehearsing was worth it.
The moment the plan lives or dies
I cloned the database to the new box and ran the checksum gate. This is the single moment the whole thing succeeds or fails.
source server : profiles=9 preds=453 trades=772 stickers=6244 sum_points=9304
new box : profiles=9 preds=453 trades=772 stickers=6244 sum_points=9304
ck_profiles d6a2d8cc == d6a2d8cc
ck_preds 48b6fb52 == 48b6fb52
ck_stickers 276ede58 == 276ede58
ck_trades 1f2f376f == 1f2f376f all match
Every count, every total, and every content fingerprint, identical on both boxes. If even one fingerprint had differed, the plan was to abort on the spot and bring the old box straight back, with nothing lost, since the old box was simply paused. I didn’t have to. They matched.
Flip the address
I brought the scoring jobs up on the new box, turned them off on the old one, and repointed the app’s address to the new dedicated tunnel. Then I watched.
For about a minute the address bounced between healthy and a “bad gateway” error. That’s expected: a change to where an address points takes a short while to spread across the network, so for a moment some requests still arrived at the old, now-stopped box. Then it settled, and stayed settled, a long unbroken run of clean, healthy responses, every one of them served from the new box.
I checked one more thing to be sure it was really the new box answering and not a stale cached copy: the responses came back marked as freshly generated, not from any cache, and each one carried a live database check that only a running server could produce. The old box’s app was stopped, so a live answer could only be coming from the new one.
The result
Total time my friends could not use the app: around ninety seconds.
The game is now live from the second location, with every point, sticker, and trade intact and verified down to the byte. The old server is sitting there powered off but ready, a fallback I can fall back to if something surprising shows up. One honest caveat I kept in front of myself: the instant players started acting on the new box, the old box’s copy began going stale, so the real safety net was that ninety-second blackout, not the old box afterward. Once people are playing again, the new box is the only truth.
A clean cutover is a good day. What I took away from it, including the things I got lucky on and the things an earlier decision quietly saved me from, is Part 11.
· 4 min read
VeePeenini, Part 11: What Moving a Live App Taught Me
The move worked. The game runs from the second location now, every point and sticker accounted for. A few things stuck with me, including one I got plain lucky on and one that an old decision quietly handled for me.
The downtime was the feature, not the failure
The reflex with any cutover is to chase zero downtime. I went the other way on purpose. With two separate databases and no shared storage, that brief “front door down” window is the only moment I can prove both sides hold exactly the same data. Trying to eliminate it would have traded a visible ninety-second blip for an invisible risk of a lost trade. For a game whose entire value is that the standings are trustworthy, that is a terrible trade. Ugly and certain beat smooth and sorry.
Verify with fingerprints, not vibes
Matching row counts feel like proof and aren’t. Two tables can line up perfectly on count and still differ in the contents. The content checksums, an md5 over the actual points and stickers and trades, are the only reason I could tell my friends “nothing was lost” without crossing my fingers. If you ever move data people care about, fingerprint it on both ends and compare. Believing is not the same as checking.
The boring stuff is what bites
The near-miss in this whole project was never the database. It was a missing notification key sitting in a config file, and a background job that wrote to the database behind the app’s back. The headline task, move the app, was never the real risk. The five unglamorous details around it were. I keep relearning this: the scary-sounding step usually goes fine, and the thing that gets you is small, quiet, and off to the side.
An old decision paid interest
Back in Part 5 I had us derive the live-match clock purely from each match’s scheduled kickoff time instead of leaning on a live-data feed. At the time it was a simplicity call. During this move it paid off in a way I didn’t plan: the new box recomputes every match window on its own, from the same schedule, with no shared state to carry over. The move was effectively invisible to the game’s logic. Decisions about where your state lives keep paying or charging you interest long after you make them.
The timezone trap I didn’t fall into
The second machine sits in a different timezone from the home server. That could have shifted every scheduled job by hours, and for a game built around daily resets and kickoff times, that’s the kind of thing that breaks quietly. It didn’t, because the app runs on UTC internally and the daily reset is pinned in code to a specific zone rather than to wherever the server happens to sit. Another case of an earlier, slightly fussy decision turning into a non-event later. The one schedule that was tied to the machine’s local clock, a nightly health check, I caught and pinned down before the flip.
The honest part
I have not yet watched the new box grade a live match in real time, with everyone in the lounge. The cutover itself is verified down to the byte, and I’m confident in it, but a server settling a match live, in front of the group, is the test that actually counts, and it’s still ahead of me. So this is a win I’m fairly sure of, not one I’ve watched survive a full match day. I’ll know more after the next kickoff, and if something does go sideways, that’s a future entry.
The album was the fun part to build. Keeping it alive and intact through a house move, without anyone losing a thing, turned out to be the part I’m quietly proudest of. We’ll see how it holds up when the next whistle blows.
Update: it held
Then the next match kicked off, played out, and ended, and I watched to see what the new server would do on its own. The answer was: all of it. It caught the final whistle, graded every prediction against the odds it had frozen at kickoff, tallied the points, dropped the loot pack, and handed out a badge, with no errors and no nudge from me. That was the one test I admitted above I hadn’t run yet. It ran, and it passed. So the move isn’t just verified down to the byte anymore. I’ve now watched it survive a live match day, which is the part I was actually waiting on.