Requests pile up in a line; the only input is a send button that pops the front one and spends a token from a bucket refilling on its own. Send with the bucket empty and the request isn’t sent at all — it’s bounced back to the head of the line, and the button locks for a backoff window that doubles with every consecutive violation. The line doesn’t wait out a lockout, either: anything sitting too long times out on its own. The skill is pacing sends to the refill rate and only spending the reserve down when the line is actually in danger, not just because it’s sitting there full.
Sibling to failover and backpressure in spirit — another take on
managing a resource that refills slower than demand — but the shape here is
a client hitting someone else’s limiter, which is the other half of that
same conversation: every SDK that ships a retry-with-backoff wrapper is
assuming exactly this failure mode on the other end of the wire.
Reuse
The mechanic lives in src/rate-limit.js as a framework-free ES module:
createRateLimit(opts)— returns a state object; callupdate(dt)each frame andsend()on input.send()pops the front queued request: with a token available it’s sent and the bucket drains by one; with none, the request goes back to the front of the queue and a lockout begins.opts:bucketCap/refillRate(the token bucket),patience(how long a request waits before timing out on its own),spawnIntervalBase/spawnIntervalMin/spawnDecay(arrival rate ramps up over a run),backoffBase/backoffGrowth(lockout length and its per-violation multiplier),violationResetWindow(how long clean before the backoff streak resets),maxTimeouts(0 disables the fail state),duration(0 = endless).- State exposes
tokens,queue(each{ id, age }, index 0 is the front),lockUntil,lastEvent({ kind: 'sent'|'throttled'|'timeout', at }, for a render flash at the gate),score(sent/throttled/timedOut/streak/bestStreak), andstatus('playing'|'finished', withreason'overwhelmed'|'time-up'). Rendering and input are entirely the caller’s job.
Port notes for DragonRuby: update() and send() have no DOM
dependencies; the queue-to-screen layout and bucket gauge belong to the
renderer, same split as the demo.
Gotchas
- A throttled request goes back to
queue[0], not the back — it’s the same request retried, not a new arrival, so itsageresets to 0 rather than carrying over the wait it already did. consecutiveViolationsonly resets afterviolationResetWindowseconds with no violation, or immediately on a clean send — so back-to-back throttles compound (backoff growsbackoffBase * backoffGrowth^n) even across a lockout window if the player mashes send the instant it clears.- Timeouts are checked only at
queue[0]inside awhileloop each tick, since only the front of a FIFO can be the oldest — no need to scan the whole queue. - The demo bundles its own copy of the module (self-contained by
contract). If you touch
src/, re-copy it intodemo/.