Chi piange per primo: il gioco delle scommesse di ReD Sposi
28 marzo 2026

Uno dei pezzi più divertenti da progettare in ReD Sposi è stato il gioco delle scommesse. Gli invitati fanno previsioni sul matrimonio — chi piange per primo, quanto durano i discorsi, la canzone del primo ballo — e accumulano punti in una classifica condivisa. L'admin (cioè io) gestisce le domande in tempo reale dal pannello.
È una feature di intrattenimento, non infrastruttura critica. Eppure mi ha richiesto più ragionamento di quanto mi aspettassi.
La state machine
Il nucleo del sistema è il campo status sul modello Bet:
open → closed → resolved
Open: gli invitati possono votare e cambiare risposta liberamente. Closed: le votazioni sono bloccate, ma non si conosce ancora il risultato — tipicamente il momento della cerimonia. Resolved: la risposta corretta è nota, i punti sono stati assegnati.
Tre stati, transizioni unidirezionali, nessun caso ambiguo. Ogni API che riceve un voto controlla che lo stato sia "open" prima di procedere; ogni tentativo su un quiz chiuso o risolto riceve un 400.
Avrei potuto usare solo due stati — aperto e risolto — ma "closed" è importante per l'esperienza: crea suspense. Gli invitati sanno che le risposte sono bloccate, ma ancora non sanno chi ha vinto. È la finestra tra "hai scommesso" e "hai vinto".
I modelli
Il modello Bet contiene la domanda bilingue (IT/EN), le opzioni, i punti in palio, lo stato, e — solo dopo la risoluzione — la risposta corretta.
Le risposte degli invitati vivono in un documento separato, BetAnswer:
{
guestId: ObjectId,
betId: ObjectId,
chosenOption: string, // id dell'opzione scelta
}
// indice unico su (guestId, betId)
L'indice unico garantisce che ogni invitato abbia al massimo una risposta per domanda. Cambiare idea — permesso finché il quiz è aperto — è un findOneAndUpdate con upsert: true, non due operazioni separate.
I punti non hanno una collezione dedicata. Vengono accumulati direttamente sul documento Guest con $inc al momento della risoluzione. La classifica è un Guest.find().sort({ points: -1 }). Non c'è niente da aggregare, niente da mantenere in sincronia.
Risoluzione e punti
Quando l'admin risolve un quiz, il backend fa tre cose in sequenza:
// 1. Trova tutti gli invitati che hanno risposto correttamente
const winners = await BetAnswer.find({ betId, chosenOption: correctAnswer });
// 2. Incrementa i punti di tutti i vincitori in un colpo solo
await Guest.updateMany(
{ _id: { $in: winners.map(w => w.guestId) } },
{ $inc: { points: bet.points } }
);
// 3. Segna il quiz come risolto
bet.status = "resolved";
bet.correctAnswer = correctAnswer;
await bet.save();
Un updateMany per tutti i vincitori, non un loop. E dopo la risoluzione, ai vincitori arriva una notifica push — "Hai indovinato! 🎉 +N punti" — e a chi ha sbagliato arriva una notifica di consolazione. Entrambe rispettano le preferenze di notifica dell'invitato.
Real-time con Socket.io
Ogni azione dell'admin emette un evento verso tutti i client connessi: bet:created, bet:updated, bet:deleted, bet:resolved. Il client mobile ascolta questi eventi e aggiorna lo stato locale senza ricaricare:
socket.on("bet:updated", (raw) => {
setBets(prev =>
prev.map(b => b.id === raw._id ? mapBet(raw, b.userChoice) : b)
);
});
Il dettaglio importante è b.userChoice: quando il backend emette un aggiornamento del quiz, non include la scelta dell'utente corrente — quella informazione è locale. mapBet accetta un secondo argomento opzionale per preservarla durante la trasformazione.
bet:resolved è l'unico evento che ricarica anche la classifica, perché i punti sono cambiati.
La BetCard e l'ottimismo
Lato mobile, ogni opzione ha quattro possibili stili visivi: neutro, selezionata (quiz aperto), corretta (quiz risolto), sbagliata-e-selezionata. Il componente li gestisce confrontando userChoice, correctAnswer e status.
La risposta viene applicata all'UI prima che la chiamata API completi:
// Prima aggiorna localmente
setBets(prev =>
prev.map(b => b.id === betId ? { ...bet, userChoice: optionId } : b)
);
// Poi invia al backend
await apiAnswerBet(betId, optionId, session);
Se la chiamata fallisce, il revert non è implementato correttamente — è un bug noto. Per un gioco di matrimonio con latenza bassa e nessun valore monetario in gioco, è accettabile. Su un sistema di betting reale non lo sarebbe.
La classifica
La classifica mostra i primi venti giocatori con podio per i primi tre — altezze proporzionali al rango, colori oro/argento/bronzo. L'utente corrente è evidenziato ovunque appaia nella lista.
Non è real-time: viene caricata on-demand. Avrei potuto agganciarla a bet:resolved per aggiornare i punti in tempo reale, ma l'effetto sarebbe stato disorientante — i numeri che cambiano mentre guardi la classifica. Meglio un reload esplicito, che avviene già automaticamente quando arriva bet:resolved.
A volte il real-time è la risposta sbagliata.
Quello che ho imparato
Progettare una feature di gioco per il tuo matrimonio ti mette in una posizione insolita: sei contemporaneamente il product manager, lo sviluppatore, e uno dei giocatori. Ho rimesso mano alla sequenza di notifiche tre volte perché come giocatore non mi sembrava giusta.
Il risultato finale è abbastanza semplice — una state machine, qualche documento MongoDB, Socket.io per il real-time. Ma la semplicità è arrivata dopo, non prima.