Rate Limiting
Every intent and request channel meters each player through a token bucket before any validator runs, and underneath it the wire transport enforces a per-heartbeat byte budget. Two layers, one design decision: enforcement is automatic, punishment is policy — and policy is yours. Dropping an over-rate message is always safe to automate; kicking a player is a game decision, so the kernel reports every drop on the bus and does nothing further.
Rate limits live server-side (in ServerScriptService, never replicated), so they cannot be patched out of client code. The bucket itself is ReplicatedStorage.ChloeKernel.Net.TokenBucket — a shared module because it is pure math, useful anywhere.
Mental model: the token bucket
Section titled “Mental model: the token bucket”A bucket holds up to Burst tokens and refills continuously at RatePerSecond — no timers, no scheduled resets:
take()first creditselapsed × RatePerSecondtokens (capped atBurst), then consumes one token if at least one is available.- A full bucket allows
Burstmessages instantly, then sustainsRatePerSecond— honest input bursts (a double-click, a reconnect flurry) pass while sustained spam blocks. - Idle time never banks more than
Burst: a minute of silence withBurst = 2still yields exactly two immediate takes. - Refill is fractional and continuous: at 10/s, a take 0.05s after emptying finds 0.5 tokens — still blocked; 0.1s finds 1.0 — allowed. All four behaviors are spec-verified (
Tests/TokenBucket.spec.luau).
The module has no internal clock: callers pass now, so behavior is deterministic and testable (it defaults to os.clock() in production). A take costs 58ns (17.0M ops/s) — see Benchmarks. The full intent security pipeline — rate limit, session check, three validators — stays well under a microsecond per packet, which is why metering everything is the default rather than an option.
How channels are wired
Section titled “How channels are wired”The NetDriver gate runs, in order, for every inbound intent and request:
- Session check — a player without a session (not joined yet, already leaving) is dropped before any bucket exists. Order matters: a deferred message landing after
PlayerRemovingwould otherwise key a fresh bucket to a departedPlayerand leak it. - Token bucket — one bucket per player per channel, created lazily on the player’s first message to that channel with the channel’s
RateLimit/Burst. No token: the message is dropped,Net.RateLimitedfires (hook + bus),Net.Stats.IntentsRateLimitedincrements. - Only then the fail-closed hook chain and handler.
Buckets are dropped on Kernel.SessionEnd, so nothing accumulates across visits.
What a rate-limited client observes, per channel kind:
| Channel kind | On rate limit |
|---|---|
| Intent | Silently dropped |
| Request | Resumes with the channel’s RejectValue — same encoding as a validator rejection or timeout; nothing leaks |
| Predicted intent | No ack — the client’s prediction times out and rolls back. Spam burns the spammer’s own predictions |
Configuring a channel
Section titled “Configuring a channel”Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5, -- tokens/second/player; default 30 Burst = 10, -- bucket depth; default max(1, ceil(RateLimit)) Handler = function(session, itemId) applyItem(session, itemId) end,})Both knobs are validated at define time — a bad limit is a boot error, not a silent hole:
RateLimitmust be a finite number > 0. A non-positive or non-finite rate means the bucket never refills.Burst, if given, must be a finite number ≥ 1. An unbounded burst disables the limiter.
The same options ride defineRequest, definePredictedIntent, and Registry channel definitions (RateLimit = 5, Burst = 10 in the def table).
The default is a flag, not a blessing
Section titled “The default is a flag, not a blessing”Channels that never set RateLimit run at 30/s — and the driver remembers (ExplicitRate). Kernel:securityAudit() reports every handler-bearing channel still riding the default as an Info finding:
[Info] DefaultRateLimit — intent UseItem rides the default rate limit (30/s) — set RateLimit to what the gameplay actually needs30/s is a ceiling against runaway loops, not a gameplay number. An ability a human triggers twice a second should say RateLimit = 5 — the gap between “what the game needs” and “what the default allows” is free spam headroom. See Fuzz & Audit for the full sweep.
The second layer: the transport flood cap
Section titled “The second layer: the transport flood cap”Channel buckets meter messages on defined channels. Below them, the Packet transport enforces a byte budget per player per heartbeat on everything inbound:
- Default 8,000 bytes/heartbeat, live-tunable via the
RateLimitBytesattribute on the Packet module. - Every inbound
RemoteEventinvocation counts at least 800 bytes, so tiny-payload spam caps at ~10 invocations per heartbeat. - An over-budget payload is dropped whole — every message batched inside it — and the driver surfaces it as
Net.FloodDetected, countingNet.Stats.TransportFloodsandTransportFloodBytes.
The two layers catch different attackers:
| Channel token bucket | Transport flood cap | |
|---|---|---|
| Scope | Per player × per channel | Per player, all inbound traffic |
| Unit | Messages (1 token each, size-blind) | Bytes (min 800/invocation) |
| Window | Continuous refill | Per heartbeat, counter resets each flush |
| Configured by | RateLimit/Burst per channel |
RateLimitBytes attribute (default 8,000) |
| Catches | A decodable client hammering one channel | Raw floods, oversized payloads, garbage that never decodes |
| On exceed | One message dropped / RejectValue |
Entire payload dropped |
| Reported as | Net.RateLimited |
Net.FloodDetected |
A client tripping the transport cap is a stronger signal than one tripping a channel bucket: channel limits catch enthusiasm and bugs; byte floods are rarely honest.
Hooks & bus topics
Section titled “Hooks & bus topics”Both fire on every drop. The hook points are declared FailOpen — they are observation points, not gates; there is nothing downstream to protect, and a crashing observer must not change what the driver already decided.
| Hook point | Context | Mode |
|---|---|---|
Net.RateLimited |
{ Player: Player, Channel: string } |
Fail-open |
Net.FloodDetected |
{ Player: Player, Bytes: number, PayloadBytes: number?, BudgetBytes: number? } |
Fail-open |
| Bus topic | Payload |
|---|---|
Net.RateLimited |
(player: Player, channelName: string) |
Net.FloodDetected |
(player: Player, bytes: number, payloadBytes: number?, budgetBytes: number?) — bytes is the player’s running heartbeat total including this payload, payloadBytes the offending payload alone, budgetBytes the configured cap |
Adjacent but distinct: Net.IntentRejected (player, channelName) publishes when a message passed the rate limit and was then rejected by the validation chain — a different signal (probing/tampering rather than flooding) worth separate weighting in your policy.
Policy is yours: a strike system
Section titled “Policy is yours: a strike system”The kernel drops; you decide what repeated dropping means. A complete example — weighted strikes in a rolling window, transport floods weighted heavier:
local Strikes: { [Player]: { Count: number, WindowStart: number } } = {}local WindowSeconds = 30local KickThreshold = 12
local function noteStrike(player: Player, weight: number) local Now = os.clock() local Entry = Strikes[player] if not Entry or Now - Entry.WindowStart > WindowSeconds then Entry = { Count = 0, WindowStart = Now } Strikes[player] = Entry end Entry.Count += weight if Entry.Count >= KickThreshold then Strikes[player] = nil player:Kick("Network abuse detected") endend
-- Channel-level: bursty spam on one channel — light hand, honest lag hits this tooKernel.Bus:subscribe("Net.RateLimited", function(_, player, channelName) noteStrike(player, 1)end)
-- Transport-level flooding (Packet's 8KB/heartbeat cap) — heavier handKernel.Bus:subscribe("Net.FloodDetected", function(_, player, bytes, payloadBytes, budgetBytes) noteStrike(player, 4)end)
Kernel.Bus:subscribe("Kernel.SessionEnd", function(_, session) Strikes[session.Player] = nilend)Why the split exists: an honest player on a bad connection will occasionally trip a channel limit (client-side retries, input buffering after a freeze). Instant punishment punishes lag. A windowed, weighted policy separates “laggy” from “hostile” — and only your game knows where that line is.
Using TokenBucket directly
Section titled “Using TokenBucket directly”The bucket is a plain module — use it for anything that needs metering, not just the wire:
local ReplicatedStorage = game:GetService("ReplicatedStorage")local TokenBucket = require(ReplicatedStorage.ChloeKernel.Net.TokenBucket)
-- 2 chat messages/second, bursts of 5local ChatBucket = TokenBucket.new(2, 5)
local function onChat(message: string) if not ChatBucket:take() then return -- over rate; drop or queue end acceptMessage(message)endDeterministic in tests — pass the clock:
local Bucket = TokenBucket.new(10, 3, 0) -- rate 10/s, burst 3, "now" is 0assert(Bucket:take(0)) -- 3 immediate takes passassert(Bucket:take(0))assert(Bucket:take(0))assert(not Bucket:take(0)) -- emptyassert(not Bucket:take(0.05)) -- 0.5 tokens accrued: still blockedassert(Bucket:take(0.1)) -- 1.0 token accrued: allowedAPI reference
Section titled “API reference”| Member | Description |
|---|---|
TokenBucket.new(ratePerSecond: number, burst: number?, now: number?) → TokenBucket |
Starts full (Tokens = Burst). burst defaults to max(1, ceil(ratePerSecond)); now defaults to os.clock() |
bucket:take(now: number?) → boolean |
Refills from elapsed time (capped at Burst), then consumes one token if available. now defaults to os.clock() |
bucket.RatePerSecond / bucket.Burst / bucket.Tokens / bucket.LastRefill |
Plain fields — readable for debugging, adjustable live if you know what you are doing |
Driver-side counters on Net.Stats: IntentsRateLimited, TransportFloods, TransportFloodBytes (plus IntentsAccepted/Rejected, RequestsRejected) — see NetDriver.
Gotchas
Section titled “Gotchas”- Buckets are per channel — a spammer throttled on one channel starts fresh on another. The transport cap is the aggregate backstop; your strike policy is the memory across channels.
- Passing a non-monotonic
now(a clock that goes backwards) is not defended: negative elapsed time simply skips the refill. Useos.clock()or a monotonic test clock. - If a legitimate mechanic needs a client to pace itself, mirror a bucket client-side for UX (grey the button out) — but the server-side bucket remains the enforcement; the client copy is cosmetic.