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>
556 lines
16 KiB
Go
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
|
|
}
|