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

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)
}
}
}