← Blog
react nativenode.jssocket.iomongodbprodotto

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

28 marzo 2026

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

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.

Schermata del gioco scommesse su iPhone

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.

Diagramma stati: Aperto → Chiuso → Risolto

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.