VeePeenini, Part 2: Making It Real
· Vitor Pontual · 5 min read
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.