Who cries first: the ReD Sposi betting game
28 March 2026

One of the most enjoyable things to design in ReD Sposi was the betting game. Guests make predictions about the wedding — who cries first, how long the speeches run, what song plays for the first dance — and accumulate points on a shared leaderboard. The admin (me) manages the questions in real-time from the panel.
It's an entertainment feature, not critical infrastructure. And yet it required more thought than I expected.
The state machine
The core of the system is the status field on the Bet model:
open → closed → resolved
Open: guests can vote and change their answer freely. Closed: voting is locked, but the result isn't known yet — typically the window during the ceremony. Resolved: the correct answer is set, points have been assigned.
Three states, one-way transitions, no ambiguous cases. Every API that receives a vote checks that the status is "open" before proceeding; any attempt on a closed or resolved quiz gets a 400.
I could have used just two states — open and resolved — but "closed" matters for the experience: it creates suspense. Guests know their answers are locked in, but they don't know yet who won. It's the gap between "you placed your bet" and "you won."
The models
The Bet model contains the bilingual question (IT/EN), the options, the points at stake, the status, and — only after resolution — the correct answer.
Guest answers live in a separate document, BetAnswer:
{
guestId: ObjectId,
betId: ObjectId,
chosenOption: string, // id of the chosen option
}
// unique index on (guestId, betId)
The unique index guarantees each guest has at most one answer per question. Changing your mind — allowed while the quiz is open — is a findOneAndUpdate with upsert: true, not two separate operations.
Points don't have their own collection. They accumulate directly on the Guest document via $inc at resolution time. The leaderboard is a Guest.find().sort({ points: -1 }). Nothing to aggregate, nothing to keep in sync.
Resolution and points
When the admin resolves a quiz, the backend does three things in sequence:
// 1. Find everyone who answered correctly
const winners = await BetAnswer.find({ betId, chosenOption: correctAnswer });
// 2. Increment points for all winners in one shot
await Guest.updateMany(
{ _id: { $in: winners.map(w => w.guestId) } },
{ $inc: { points: bet.points } }
);
// 3. Mark the quiz as resolved
bet.status = "resolved";
bet.correctAnswer = correctAnswer;
await bet.save();
One updateMany for all winners, not a loop. After resolution, winners get a push notification — "You got it! 🎉 +N points" — and those who were wrong get a consolation notification. Both respect the guest's notification preferences.
Real-time with Socket.io
Every admin action emits an event to all connected clients: bet:created, bet:updated, bet:deleted, bet:resolved. The mobile client listens to these events and updates local state without reloading:
socket.on("bet:updated", (raw) => {
setBets(prev =>
prev.map(b => b.id === raw._id ? mapBet(raw, b.userChoice) : b)
);
});
The important detail is b.userChoice: when the backend emits a quiz update, it doesn't include the current user's choice — that information is local. mapBet takes an optional second argument to preserve it during the transformation.
bet:resolved is the only event that also reloads the leaderboard, because points have changed.
The BetCard and optimism
On the mobile side, each option has four possible visual states: neutral, selected (quiz open), correct (quiz resolved), wrong-and-selected. The component handles them by comparing userChoice, correctAnswer, and status.
The answer is applied to the UI before the API call completes:
// Update locally first
setBets(prev =>
prev.map(b => b.id === betId ? { ...bet, userChoice: optionId } : b)
);
// Then send to backend
await apiAnswerBet(betId, optionId, session);
If the call fails, the revert isn't implemented correctly — it's a known bug. For a wedding game with low latency and no monetary value at stake, that's acceptable. On a real betting system, it wouldn't be.
The leaderboard
The leaderboard shows the top twenty players, with a podium for the top three — heights proportional to rank, gold/silver/bronze colors. The current user is highlighted wherever they appear in the list.
It's not real-time: it loads on demand. I could have hooked it into bet:resolved to update points live, but the effect would have been disorienting — numbers changing while you're looking at the leaderboard. Better an explicit reload, which already happens automatically when bet:resolved arrives.
Sometimes real-time is the wrong answer.
What I learned
Designing a game feature for your own wedding puts you in an unusual position: you're simultaneously the product manager, the developer, and one of the players. I reworked the notification sequence three times because as a player it didn't feel right.
The end result is fairly simple — a state machine, a few MongoDB documents, Socket.io for real-time. But the simplicity came after, not before.