Skip to content

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.

A bucket holds up to Burst tokens and refills continuously at RatePerSecond — no timers, no scheduled resets:

  • take() first credits elapsed × RatePerSecond tokens (capped at Burst), then consumes one token if at least one is available.
  • A full bucket allows Burst messages instantly, then sustains RatePerSecond — 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 with Burst = 2 still 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.

The NetDriver gate runs, in order, for every inbound intent and request:

  1. 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 PlayerRemoving would otherwise key a fresh bucket to a departed Player and leak it.
  2. 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.RateLimited fires (hook + bus), Net.Stats.IntentsRateLimited increments.
  3. 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
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:

  • RateLimit must 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).

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 needs

30/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.

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 RateLimitBytes attribute on the Packet module.
  • Every inbound RemoteEvent invocation 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, counting Net.Stats.TransportFloods and TransportFloodBytes.

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.

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.

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 = 30
local 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")
end
end
-- Channel-level: bursty spam on one channel — light hand, honest lag hits this too
Kernel.Bus:subscribe("Net.RateLimited", function(_, player, channelName)
noteStrike(player, 1)
end)
-- Transport-level flooding (Packet's 8KB/heartbeat cap) — heavier hand
Kernel.Bus:subscribe("Net.FloodDetected", function(_, player, bytes, payloadBytes, budgetBytes)
noteStrike(player, 4)
end)
Kernel.Bus:subscribe("Kernel.SessionEnd", function(_, session)
Strikes[session.Player] = nil
end)

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.

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 5
local ChatBucket = TokenBucket.new(2, 5)
local function onChat(message: string)
if not ChatBucket:take() then
return -- over rate; drop or queue
end
acceptMessage(message)
end

Deterministic in tests — pass the clock:

local Bucket = TokenBucket.new(10, 3, 0) -- rate 10/s, burst 3, "now" is 0
assert(Bucket:take(0)) -- 3 immediate takes pass
assert(Bucket:take(0))
assert(Bucket:take(0))
assert(not Bucket:take(0)) -- empty
assert(not Bucket:take(0.05)) -- 0.5 tokens accrued: still blocked
assert(Bucket:take(0.1)) -- 1.0 token accrued: allowed
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.

  • 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. Use os.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.