Files
casino/pkg/room/room.go
drjones 038550b6ff perf: single-statement postings, marshal-once broadcast, client interpolation
Measured, then fixed, the three things that made a crowd impossible.

Ledger: Post issued three round trips per posting, so settlement scaled
in network latency rather than work. It is now two statements regardless
of leg count — settling 1000 winners went 844ms to 220ms. The lock and
the balance read must stay separate statements: a single statement, even
one whose CTE does FOR UPDATE, evaluates against a snapshot taken before
the locks are held, so concurrent transactions read stale balances and
money disappears. The conservation tests caught exactly that.

Broadcast: every connection marshalled its own copy, ~355us each. At any
real crowd that exceeds the tick interval by orders of magnitude. Frames
are now serialised once per broadcast and shared.

Feed: the player list is capped at 24 and carries no public keys, and
running rounds broadcast at 5Hz instead of 60Hz. Clients compute the
multiplier locally from the round start time, which the deterministic
curve makes exact. Frame size fell from 3.6KB to 1.8KB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:36:30 +00:00

556 lines
16 KiB
Go

// 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/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
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,
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 postings []ledger.Posting
var housePays int64
for _, b := range bets {
if b.CashedOutAt == 0 {
continue // rode it into the crash; the stake already sits with the house
}
payout := b.StakeMsat * int64(b.CashedOutAt) / int64(fixed.One)
b.PayoutMsat = payout
if payout > 0 {
postings = append(postings, ledger.Posting{AccountID: b.AccountID, AmountMsat: payout})
housePays += payout
}
if _, err := r.pool.Exec(ctx,
`UPDATE bets SET payout_msat = $2, settled_at = now()
WHERE round_id = $1 AND account_id = $3`,
roundID, payout, b.AccountID); err != nil {
return fmt.Errorf("recording payout: %w", err)
}
}
if housePays > 0 {
postings = append(postings, ledger.Posting{AccountID: house, AmountMsat: -housePays})
rid := roundID
if _, err := r.ledger.Post(ctx, "payout", &rid, postings); err != nil {
return fmt.Errorf("settling 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
}