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.
7 entries · May 24 – Jun 4, 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.