// Package room runs the shared crash rounds. // // A round moves through four states: betting_open, locked, running, settled. // The server seed is committed before betting opens and revealed only at // settlement, so no one — including the operator — can know the crash point // while bets are still being placed. // // All money movement goes through the ledger in a single transaction per // settlement, which is what keeps the books balanced under load. package room import ( "context" "encoding/hex" "encoding/json" "fmt" "sort" "sync" "time" "github.com/drjones/quantum-arcade/pkg/fair" "github.com/drjones/quantum-arcade/pkg/fees" "github.com/drjones/quantum-arcade/pkg/fixed" "github.com/drjones/quantum-arcade/pkg/ledger" "github.com/drjones/quantum-arcade/pkg/sim" "github.com/jackc/pgx/v5/pgxpool" ) // State is the phase of a round. type State string const ( StateBetting State = "betting_open" StateLocked State = "locked" StateRunning State = "running" StateSettled State = "settled" ) // Timings. The betting window is deliberately generous: at a party, people are // walking up to their phones mid-round. const ( BettingWindow = 20 * time.Second LockedPause = 3 * time.Second SettledPause = 7 * time.Second TickInterval = time.Second / sim.TickHz ) // Bet is one player's position in the current round. type Bet struct { AccountID int64 Pubkey []byte Nickname string StakeMsat int64 CashedOutAt fixed.F // zero until they cash out PayoutMsat int64 // AutoCashOutAt is an optional target set before the round starts. When // the multiplier reaches it the position closes automatically at exactly // that value — not at whatever the next tick happens to show — so the // player gets the number they chose. Zero means no target. AutoCashOutAt fixed.F } // Snapshot is what clients render. It carries the seed inputs so a client can // verify the round the moment it settles. type Snapshot struct { RoundID int64 `json:"round_id"` Game string `json:"game"` State State `json:"state"` Tick int `json:"tick"` Multiplier string `json:"multiplier"` Commitment string `json:"commitment"` ServerSeed string `json:"server_seed,omitempty"` // only once settled CrashPoint string `json:"crash_point,omitempty"` // only once settled // Players is capped at MaxListedPlayers. Sending every player to every // subscriber is O(n^2) in bandwidth and makes a large room impossible: // 50k players broadcast to 50k phones is gigabytes per second. The full // list is available on request; the feed carries the leaderboard. Players []Player `json:"players"` PlayerCount int `json:"player_count"` PotMsat int64 `json:"pot_msat"` CashedOut int `json:"cashed_out_count"` HousePotMsat int64 `json:"house_pot_msat"` NextPhaseIn float64 `json:"next_phase_in_seconds"` // StartedUnixMilli is when the running phase began. Because the multiplier // curve is deterministic, a client can compute the current value locally // from this instead of being told it sixty times a second. StartedUnixMilli int64 `json:"started_unix_milli,omitempty"` } // MaxListedPlayers bounds the per-frame player list. const MaxListedPlayers = 24 // BroadcastHz is how often a running round pushes a frame. Clients compute the // multiplier locally between frames, so this only has to be often enough to // correct drift and deliver cash-out news. const BroadcastHz = 5 // Player is the public view of a participant. type Player struct { Nickname string `json:"nickname"` // Pubkey is omitted from the live feed: it is 64 hex characters, it is // most of the frame, and nothing in the interface displays it. The full // participant list, with keys, is served by the verification endpoint // after settlement — which is where it actually matters. PubkeyHex string `json:"pubkey,omitempty"` StakeMsat int64 `json:"stake_msat"` CashedOut string `json:"cashed_out,omitempty"` PayoutMsat int64 `json:"payout_msat"` // Auto is true when the position closed on its own target rather than a tap. Auto bool `json:"auto,omitempty"` } // Room runs one game's round loop. type Room struct { Game string pool *pgxpool.Pool ledger *ledger.Ledger // Fees is the operator's schedule. Deductions are posted as their own // ledger transaction rather than folded into the payout, so a player's // history shows the win and the fee as separate, itemised lines. Fees fees.Schedule mu sync.RWMutex roundID int64 state State tick int nonce uint64 serverSeed fair.ServerSeed commitment [32]byte crashPoint fixed.F bets map[int64]*Bet order [][]byte // participant pubkeys in join order phaseEnds time.Time subscribers map[chan []byte]struct{} subMu sync.Mutex // runStarted is when the current running phase began. runStarted time.Time } func New(game string, pool *pgxpool.Pool, l *ledger.Ledger) *Room { return &Room{ Game: game, pool: pool, ledger: l, Fees: fees.DefaultSchedule(), state: StateSettled, bets: make(map[int64]*Bet), subscribers: make(map[chan []byte]struct{}), phaseEnds: time.Now(), } } // Subscribe returns a channel of snapshots. The channel is buffered and drops // updates rather than blocking the round loop: a slow phone must never stall // the game for everyone else. func (r *Room) Subscribe() (<-chan []byte, func()) { ch := make(chan []byte, 4) r.subMu.Lock() r.subscribers[ch] = struct{}{} r.subMu.Unlock() return ch, func() { r.subMu.Lock() delete(r.subscribers, ch) close(ch) r.subMu.Unlock() } } // broadcast serialises the snapshot once and hands the same bytes to every // subscriber. // // Letting each connection marshal its own copy costs ~355us per subscriber per // frame, which at any real crowd size exceeds the tick interval by orders of // magnitude. One marshal per frame turns fan-out into a pointer copy. func (r *Room) broadcast() { payload, err := json.Marshal(r.Snapshot()) if err != nil { fmt.Printf("room %s: marshalling snapshot: %v\n", r.Game, err) return } r.subMu.Lock() defer r.subMu.Unlock() for ch := range r.subscribers { select { case ch <- payload: default: // subscriber is behind; drop this frame rather than stall } } } // Run drives the round loop until the context is cancelled. func (r *Room) Run(ctx context.Context) error { ticker := time.NewTicker(TickInterval) defer ticker.Stop() for { select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: if err := r.step(ctx); err != nil { // A failed step must not kill the room; log-and-continue keeps // the arcade running even if the database blips. fmt.Printf("room %s: step error: %v\n", r.Game, err) } } } } func (r *Room) step(ctx context.Context) error { r.mu.Lock() state, phaseEnds := r.state, r.phaseEnds r.mu.Unlock() now := time.Now() switch state { case StateSettled: if now.After(phaseEnds) { return r.openRound(ctx) } case StateBetting: if now.After(phaseEnds) { r.mu.Lock() r.state = StateLocked r.phaseEnds = now.Add(LockedPause) r.mu.Unlock() r.broadcast() } case StateLocked: if now.After(phaseEnds) { return r.startRunning(ctx) } case StateRunning: r.mu.Lock() r.tick++ reached := sim.MultiplierAt(r.tick) // Close any positions whose target has been met. This happens before // the crash check so a target at or below the crash point always pays, // regardless of where tick boundaries happen to fall. r.triggerAutoCashOutsLocked(reached) // A crash point beyond what the curve expresses would otherwise never // be reached, so the tick ceiling also ends the round. crashed := reached >= r.crashPoint || r.tick >= sim.RoundTicks r.mu.Unlock() if crashed { return r.settle(ctx) } // The multiplier is a pure function of the tick, and the client has // the same curve. So the feed does not need to carry it sixty times a // second: clients interpolate locally from StartedUnixMilli and the // server sends a correcting frame a few times a second. // // At 60Hz this fan-out was the single largest cost in the system. At // BroadcastHz it is a rounding error, and the animation is smoother // because it is no longer gated on network jitter. if r.tick%(sim.TickHz/BroadcastHz) == 0 { r.broadcast() } } return nil } // openRound commits to a fresh server seed and opens betting. func (r *Room) openRound(ctx context.Context) error { seed := fair.NewServerSeed() commitment := seed.Commitment() r.mu.Lock() r.nonce++ nonce := r.nonce r.mu.Unlock() var roundID int64 err := r.pool.QueryRow(ctx, `INSERT INTO rounds (game, nonce, commitment) VALUES ($1, $2, $3) RETURNING id`, r.Game, int64(nonce), commitment[:]).Scan(&roundID) if err != nil { return fmt.Errorf("opening round: %w", err) } r.mu.Lock() r.roundID = roundID r.serverSeed = seed r.commitment = commitment r.crashPoint = 0 r.tick = 0 r.bets = make(map[int64]*Bet) r.order = nil r.state = StateBetting r.phaseEnds = time.Now().Add(BettingWindow) r.mu.Unlock() r.broadcast() return nil } // startRunning derives the crash point from the committed seed and the // participant set, then begins the climb. func (r *Room) startRunning(ctx context.Context) error { r.mu.Lock() clientSeed := fair.ClientSeed(r.order) roundSeed := fair.RoundSeed(r.serverSeed, clientSeed, r.nonce) r.crashPoint = sim.CrashPoint(roundSeed) r.state = StateRunning r.tick = 0 r.runStarted = time.Now() roundID := r.roundID crash := r.crashPoint r.mu.Unlock() if _, err := r.pool.Exec(ctx, `UPDATE rounds SET locked_at = now(), client_seed = $2, crash_point = $3 WHERE id = $1`, roundID, clientSeed[:], int64(crash)); err != nil { return fmt.Errorf("locking round: %w", err) } r.broadcast() return nil } // settle pays out everyone who cashed out in time and reveals the seed. // Payouts are written as one ledger transaction so the books cannot be left // half-updated. func (r *Room) settle(ctx context.Context) error { r.mu.Lock() roundID := r.roundID seed := r.serverSeed crash := r.crashPoint bets := make([]*Bet, 0, len(r.bets)) for _, b := range r.bets { bets = append(bets, b) } r.state = StateSettled r.phaseEnds = time.Now().Add(SettledPause) r.mu.Unlock() house, err := r.ledger.AccountByName(ctx, "house_pot") if err != nil { return err } var payouts []ledger.Posting var feeLines []ledger.Posting var housePays, houseKeeps int64 for _, b := range bets { if b.CashedOutAt == 0 { continue // rode it into the crash; the stake already sits with the house } gross := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One) split := r.Fees.Apply(gross) b.PayoutMsat = split.NetMsat if gross > 0 { payouts = append(payouts, ledger.Posting{AccountID: b.AccountID, AmountMsat: gross}) housePays += gross } if split.HouseMsat() > 0 { feeLines = append(feeLines, ledger.Posting{ AccountID: b.AccountID, AmountMsat: -split.HouseMsat()}) houseKeeps += split.HouseMsat() } if _, err := r.pool.Exec(ctx, `UPDATE bets SET payout_msat = $2, rake_msat = $4, rounding_msat = $5, settled_at = now() WHERE round_id = $1 AND account_id = $3`, roundID, split.NetMsat, b.AccountID, split.RakeMsat, split.RoundingMsat); err != nil { return fmt.Errorf("recording payout: %w", err) } } rid := roundID // Pay the full winnings first, then take the fee as its own transaction. // Netting them into one posting would be arithmetically identical but // would hide the deduction: the player would see a smaller win rather // than a win and a charge. if housePays > 0 { payouts = append(payouts, ledger.Posting{AccountID: house, AmountMsat: -housePays}) if _, err := r.ledger.Post(ctx, "payout", &rid, payouts); err != nil { return fmt.Errorf("settling round %d: %w", roundID, err) } } if houseKeeps > 0 { feeLines = append(feeLines, ledger.Posting{AccountID: house, AmountMsat: houseKeeps}) if _, err := r.ledger.Post(ctx, "operating_fee", &rid, feeLines); err != nil { return fmt.Errorf("collecting fees for round %d: %w", roundID, err) } } // pgx encodes byte slices, not fixed-size arrays, so the seed is sliced. seedBytes := seed.Bytes() if _, err := r.pool.Exec(ctx, `UPDATE rounds SET settled_at = now(), server_seed = $2 WHERE id = $1`, roundID, seedBytes[:]); err != nil { return fmt.Errorf("revealing seed: %w", err) } _ = crash r.broadcast() return nil } // triggerAutoCashOutsLocked closes positions whose target the multiplier has // reached. Callers must hold the lock. // // A target above the crash point never fires: the round is already over at // that value. A target at or below it always fires, at exactly the target. func (r *Room) triggerAutoCashOutsLocked(reached fixed.F) { for _, b := range r.bets { if b.CashedOutAt != 0 || b.AutoCashOutAt == 0 { continue } if b.AutoCashOutAt > r.crashPoint { continue // the round ends before this target is reached } if reached >= b.AutoCashOutAt { b.CashedOutAt = b.AutoCashOutAt } } } // PlaceBet takes a stake during the betting window. The stake moves to the // house immediately, so a player can never bet money they do not have. func (r *Room) PlaceBet(ctx context.Context, accountID int64, pubkey []byte, nickname string, stakeMsat int64, autoCashOutAt fixed.F) error { if stakeMsat <= 0 { return ledger.ErrNonPositiveAmount } // A target at or below 1.0 would close instantly for no gain. if autoCashOutAt != 0 && autoCashOutAt <= fixed.One { return fmt.Errorf("auto cash-out target must be above 1.00") } r.mu.Lock() if r.state != StateBetting { r.mu.Unlock() return fmt.Errorf("betting is closed") } if _, exists := r.bets[accountID]; exists { r.mu.Unlock() return fmt.Errorf("already in this round") } roundID := r.roundID r.mu.Unlock() house, err := r.ledger.AccountByName(ctx, "house_pot") if err != nil { return err } rid := roundID if _, err := r.ledger.Post(ctx, "bet", &rid, []ledger.Posting{ {AccountID: accountID, AmountMsat: -stakeMsat}, {AccountID: house, AmountMsat: stakeMsat}, }); err != nil { return err } if _, err := r.pool.Exec(ctx, `INSERT INTO bets (round_id, account_id, stake_msat) VALUES ($1, $2, $3)`, roundID, accountID, stakeMsat); err != nil { return err } r.mu.Lock() // Re-check state: the window may have closed while we were in the database. if r.state != StateBetting || r.roundID != roundID { r.mu.Unlock() return fmt.Errorf("betting closed while placing bet") } r.bets[accountID] = &Bet{ AccountID: accountID, Pubkey: pubkey, Nickname: nickname, StakeMsat: stakeMsat, AutoCashOutAt: autoCashOutAt, } r.order = append(r.order, pubkey) r.mu.Unlock() r.broadcast() return nil } // CashOut locks in the current multiplier. It is rejected once the round has // passed the crash point, which the tick loop enforces by settling first. func (r *Room) CashOut(accountID int64) (fixed.F, error) { r.mu.Lock() defer r.mu.Unlock() if r.state != StateRunning { return 0, fmt.Errorf("round is not running") } b, ok := r.bets[accountID] if !ok { return 0, fmt.Errorf("no bet in this round") } if b.CashedOutAt != 0 { return 0, fmt.Errorf("already cashed out") } at := sim.MultiplierAt(r.tick) if at >= r.crashPoint { return 0, fmt.Errorf("too late") } b.CashedOutAt = at go func() { if _, err := r.pool.Exec(context.Background(), `UPDATE bets SET cashout_at = $3 WHERE round_id = $1 AND account_id = $2`, r.roundID, accountID, int64(at)); err != nil { fmt.Printf("room %s: recording cashout: %v\n", r.Game, err) } }() return at, nil } // Snapshot renders the current state for clients. func (r *Room) Snapshot() Snapshot { r.mu.RLock() defer r.mu.RUnlock() // Aggregate over every player, but only serialise the largest few. var pot int64 cashed := 0 all := make([]*Bet, 0, len(r.bets)) for _, b := range r.bets { pot += b.StakeMsat if b.CashedOutAt != 0 { cashed++ } all = append(all, b) } // Partial ordering is enough: the list is a leaderboard, not a ledger. sort.Slice(all, func(i, j int) bool { return all[i].StakeMsat > all[j].StakeMsat }) if len(all) > MaxListedPlayers { all = all[:MaxListedPlayers] } players := make([]Player, 0, len(all)) for _, b := range all { p := Player{ Nickname: b.Nickname, StakeMsat: b.StakeMsat, PayoutMsat: b.PayoutMsat, } if b.CashedOutAt != 0 { p.CashedOut = b.CashedOutAt.String() p.Auto = b.AutoCashOutAt != 0 && b.CashedOutAt == b.AutoCashOutAt } players = append(players, p) } s := Snapshot{ RoundID: r.roundID, Game: r.Game, State: r.state, Tick: r.tick, Multiplier: sim.MultiplierAt(r.tick).String(), Commitment: hex.EncodeToString(r.commitment[:]), Players: players, PlayerCount: len(r.bets), PotMsat: pot, CashedOut: cashed, NextPhaseIn: time.Until(r.phaseEnds).Seconds(), } if r.state == StateRunning { s.StartedUnixMilli = r.runStarted.UnixMilli() } // The seed is revealed only once the round is over. if r.state == StateSettled && r.crashPoint != 0 { s.ServerSeed = r.serverSeed.Hex() s.CrashPoint = r.crashPoint.String() } return s }