VeePeenini, Part 4: Correctness Before Kickoff
· Vitor Pontual · 4 min read
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.