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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user