feat: playable arcade — rooms, identity, client, deployment

Round length is now bounded: the multiplier follows a hyperbolic curve
diverging at 60s, replacing an exponential one where a 275x crash point
produced a two-and-a-half minute round.

Fixes seed reveal, which silently failed every round because pgx cannot
encode a fixed-size byte array as bytea.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
drjones
2026-08-05 15:34:52 +00:00
parent 41c1bb2fdf
commit 2a2a1db8de
17 changed files with 2885 additions and 25 deletions

116
pkg/identity/identity.go Normal file
View File

@@ -0,0 +1,116 @@
// Package identity implements keypair-based sign-in.
//
// There are no accounts in the usual sense: a player's ed25519 public key is
// their identity. To prove ownership they sign a server-issued challenge, which
// is single-use and short-lived. There is no password to leak, no email to
// verify, and nothing to reset — losing the key loses the balance, which is
// stated plainly in the interface.
package identity
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"sync"
"time"
)
var (
ErrUnknownChallenge = errors.New("identity: challenge not found or expired")
ErrBadSignature = errors.New("identity: signature does not verify")
ErrBadPublicKey = errors.New("identity: malformed public key")
)
// ChallengeTTL is how long a challenge stays valid. Short, because a client
// signs it immediately.
const ChallengeTTL = 2 * time.Minute
type challenge struct {
nonce [32]byte
expires time.Time
}
// Authenticator issues and verifies sign-in challenges.
type Authenticator struct {
mu sync.Mutex
challenges map[string]challenge
now func() time.Time
}
func NewAuthenticator() *Authenticator {
return &Authenticator{
challenges: make(map[string]challenge),
now: time.Now,
}
}
// Challenge issues a fresh nonce for a public key to sign.
func (a *Authenticator) Challenge(pubkeyHex string) (string, error) {
pk, err := ParsePublicKey(pubkeyHex)
if err != nil {
return "", err
}
var n [32]byte
if _, err := rand.Read(n[:]); err != nil {
panic("identity: system randomness unavailable: " + err.Error())
}
a.mu.Lock()
defer a.mu.Unlock()
a.sweepLocked()
a.challenges[hex.EncodeToString(pk)] = challenge{
nonce: n,
expires: a.now().Add(ChallengeTTL),
}
return hex.EncodeToString(n[:]), nil
}
// Verify checks a signature over the outstanding challenge for that key and
// consumes it, so a captured signature cannot be replayed.
func (a *Authenticator) Verify(pubkeyHex, signatureHex string) error {
pk, err := ParsePublicKey(pubkeyHex)
if err != nil {
return err
}
sig, err := hex.DecodeString(signatureHex)
if err != nil || len(sig) != ed25519.SignatureSize {
return ErrBadSignature
}
a.mu.Lock()
key := hex.EncodeToString(pk)
c, ok := a.challenges[key]
if ok {
delete(a.challenges, key) // single use, whether or not it verifies
}
now := a.now()
a.mu.Unlock()
if !ok || now.After(c.expires) {
return ErrUnknownChallenge
}
if !ed25519.Verify(pk, c.nonce[:], sig) {
return ErrBadSignature
}
return nil
}
// sweepLocked drops expired challenges. Called under the mutex.
func (a *Authenticator) sweepLocked() {
now := a.now()
for k, c := range a.challenges {
if now.After(c.expires) {
delete(a.challenges, k)
}
}
}
// ParsePublicKey decodes and validates a hex-encoded ed25519 public key.
func ParsePublicKey(s string) (ed25519.PublicKey, error) {
b, err := hex.DecodeString(s)
if err != nil || len(b) != ed25519.PublicKeySize {
return nil, ErrBadPublicKey
}
return ed25519.PublicKey(b), nil
}

View File

@@ -0,0 +1,86 @@
package identity_test
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"testing"
"github.com/drjones/quantum-arcade/pkg/identity"
)
func newKey(t *testing.T) (string, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return hex.EncodeToString(pub), priv
}
func TestValidSignatureAuthenticates(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
nonceHex, err := a.Challenge(pubHex)
if err != nil {
t.Fatal(err)
}
nonce, _ := hex.DecodeString(nonceHex)
sig := ed25519.Sign(priv, nonce)
if err := a.Verify(pubHex, hex.EncodeToString(sig)); err != nil {
t.Fatalf("valid signature rejected: %v", err)
}
}
func TestWrongKeyCannotAuthenticate(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, _ := newKey(t)
_, otherPriv := newKey(t)
nonceHex, _ := a.Challenge(pubHex)
nonce, _ := hex.DecodeString(nonceHex)
sig := ed25519.Sign(otherPriv, nonce)
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrBadSignature) {
t.Fatalf("got %v, want ErrBadSignature", err)
}
}
// A captured signature must not work twice.
func TestChallengeIsSingleUse(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
nonceHex, _ := a.Challenge(pubHex)
nonce, _ := hex.DecodeString(nonceHex)
sigHex := hex.EncodeToString(ed25519.Sign(priv, nonce))
if err := a.Verify(pubHex, sigHex); err != nil {
t.Fatal(err)
}
if err := a.Verify(pubHex, sigHex); !errors.Is(err, identity.ErrUnknownChallenge) {
t.Fatalf("replay succeeded or gave %v, want ErrUnknownChallenge", err)
}
}
func TestMalformedKeyRejected(t *testing.T) {
a := identity.NewAuthenticator()
if _, err := a.Challenge("not-hex"); !errors.Is(err, identity.ErrBadPublicKey) {
t.Fatalf("got %v, want ErrBadPublicKey", err)
}
if _, err := a.Challenge("aabb"); !errors.Is(err, identity.ErrBadPublicKey) {
t.Fatalf("short key: got %v, want ErrBadPublicKey", err)
}
}
func TestVerifyWithoutChallengeFails(t *testing.T) {
a := identity.NewAuthenticator()
pubHex, priv := newKey(t)
sig := ed25519.Sign(priv, []byte("anything"))
if err := a.Verify(pubHex, hex.EncodeToString(sig)); !errors.Is(err, identity.ErrUnknownChallenge) {
t.Fatalf("got %v, want ErrUnknownChallenge", err)
}
}

446
pkg/room/room.go Normal file
View File

@@ -0,0 +1,446 @@
// 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"
"fmt"
"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
}
// 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 []Player `json:"players"`
HousePotMsat int64 `json:"house_pot_msat"`
NextPhaseIn float64 `json:"next_phase_in_seconds"`
}
// Player is the public view of a participant.
type Player struct {
Nickname string `json:"nickname"`
PubkeyHex string `json:"pubkey"`
StakeMsat int64 `json:"stake_msat"`
CashedOut string `json:"cashed_out,omitempty"`
PayoutMsat int64 `json:"payout_msat"`
}
// 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 Snapshot]struct{}
subMu sync.Mutex
}
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 Snapshot]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 Snapshot, func()) {
ch := make(chan Snapshot, 8)
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()
}
}
func (r *Room) broadcast() {
snap := r.Snapshot()
r.subMu.Lock()
defer r.subMu.Unlock()
for ch := range r.subscribers {
select {
case ch <- snap:
default: // subscriber is behind; skip this frame
}
}
}
// 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)
// 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)
}
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
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
}
// 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) error {
if stakeMsat <= 0 {
return ledger.ErrNonPositiveAmount
}
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,
}
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()
players := make([]Player, 0, len(r.bets))
for _, b := range r.bets {
p := Player{
Nickname: b.Nickname,
PubkeyHex: hex.EncodeToString(b.Pubkey),
StakeMsat: b.StakeMsat,
PayoutMsat: b.PayoutMsat,
}
if b.CashedOutAt != 0 {
p.CashedOut = b.CashedOutAt.String()
}
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,
NextPhaseIn: time.Until(r.phaseEnds).Seconds(),
}
// 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
}

View File

@@ -12,10 +12,17 @@ const HouseEdgeBP int64 = 200
// TickHz is the simulation rate. Rounds advance in whole ticks only.
const TickHz = 60
// growthPerTickBP is multiplier growth per tick, in basis points of the current
// value. At 6bp and 60Hz the multiplier reaches 2x in roughly 19 seconds, which
// is long enough to feel the climb and short enough to keep rounds moving.
const growthPerTickBP int64 = 6
// RoundTicks is the hard ceiling on a round's length: 60 seconds at 60Hz.
//
// The multiplier follows a hyperbolic curve that diverges at exactly this
// tick, so no round can run longer no matter how extreme the crash point.
// An exponential curve has no such bound — a 275x round on one takes over two
// and a half minutes, which is unplayable when a dozen people are waiting.
const RoundTicks = 60 * TickHz
// MaxMultiplier is the largest value the curve expresses, reached on the final
// tick. Crash points at or above it settle when the round hits its ceiling.
func MaxMultiplier() fixed.F { return MultiplierAt(RoundTicks - 1) }
// CrashPoint derives the multiplier at which a round ends, as a pure function of
// the seed.
@@ -48,31 +55,49 @@ func CrashPoint(seed [32]byte) fixed.F {
return cp
}
// step is the per-tick growth factor, 1 + growthPerTickBP/10000, in Q32.32.
func step() fixed.F {
return fixed.One + fixed.F(growthPerTickBP<<32/10000)
}
// MultiplierAt returns the multiplier displayed at a given tick of the round,
// compounding from 1.0.
// MultiplierAt returns the multiplier displayed at a given tick.
//
// m(t) = 1 / (1 - t/T)^2
//
// It starts at 1.0, rises slowly at first, and accelerates without bound as t
// approaches T. That acceleration is the tension: the longer you hold, the
// faster the number moves away from you, and the less time you have to react.
// It is also O(1), so a long round costs no more per tick than a short one.
func MultiplierAt(tick int) fixed.F {
m := fixed.One
s := step()
for i := 0; i < tick; i++ {
m = m.Mul(s)
if tick <= 0 {
return fixed.One
}
return m
// Clamp the tick, not the value: clamping the value would make the curve
// step backwards at the boundary if rounding put the last computed point
// above the nominal ceiling.
if tick >= RoundTicks {
tick = RoundTicks - 1
}
// remaining = 1 - tick/T, always in (0, 1].
remaining := fixed.One - fixed.FromInt(int64(tick)).Div(fixed.FromInt(RoundTicks))
return fixed.One.Div(remaining.Mul(remaining))
}
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m.
// TicksToMultiplier returns the first tick at which MultiplierAt reaches m,
// inverting the curve: t = T * (1 - 1/sqrt(m)).
func TicksToMultiplier(m fixed.F) int {
cur := fixed.One
s := step()
for tick := 0; tick < 1_000_000; tick++ {
if cur >= m {
return tick
}
cur = cur.Mul(s)
if m <= fixed.One {
return 0
}
return 1_000_000
if m >= MaxMultiplier() {
return RoundTicks
}
inv := fixed.One.Div(fixed.Sqrt(m))
t := fixed.FromInt(RoundTicks).Mul(fixed.One - inv).Int()
// Rounding in fixed point can land a tick early; step forward to the first
// tick that genuinely reaches the target.
tick := int(t)
for tick > 0 && MultiplierAt(tick-1) >= m {
tick--
}
for tick < RoundTicks && MultiplierAt(tick) < m {
tick++
}
return tick
}

View File

@@ -95,3 +95,33 @@ func TestTicksToMultiplierRoundTrips(t *testing.T) {
}
}
}
// No round may outlast the ceiling, however extreme the crash point.
func TestRoundLengthIsBounded(t *testing.T) {
if got := MultiplierAt(RoundTicks); got != MaxMultiplier() {
t.Fatalf("curve past the ceiling = %v, want %v", got, MaxMultiplier())
}
// Even the most extreme crash point settles within the ceiling.
worst := fixed.FromInt(4_000_000_000)
if tick := TicksToMultiplier(worst); tick > RoundTicks {
t.Fatalf("extreme crash point needs %d ticks, ceiling is %d", tick, RoundTicks)
}
}
// Timings that matter for how the game feels.
func TestCurveTimings(t *testing.T) {
for _, c := range []struct {
multiplier int64
maxSeconds float64
}{
{2, 20}, // the common case should arrive quickly
{10, 45},
{100, 56},
} {
tick := TicksToMultiplier(fixed.FromInt(c.multiplier))
secs := float64(tick) / TickHz
if secs > c.maxSeconds {
t.Errorf("%dx takes %.1fs, want under %.0fs", c.multiplier, secs, c.maxSeconds)
}
}
}