Rate limiting
Per-player, per-channel token buckets over Packet's byte and message budgets: how each layer meters, what it reports on the Bus, and where the strike policy goes.
- Realm
- Shared
- Require
ReplicatedStorage.ChloeKernel .Net .TokenBucket - Since
- v0.1.0
- Source
- Net/TokenBucket.luau
Every intent and request channel meters each player through a token bucket before any validator runs. Underneath, the Packet transport meters each player’s raw traffic in bytes and in frames. Every layer enforces automatically and none of them punishes. 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.
The limits live in server memory (the buckets are created and checked by
NetDriver and Packet on the server), so they cannot be patched out of client code. The bucket
module itself, ReplicatedStorage.ChloeKernel.Net.TokenBucket, is shared because it is plain arithmetic and 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, with no timers or scheduled resets:
take()first creditselapsed × RatePerSecondtokens (capped atBurst), then consumes one token if at least one is available.- A full bucket allows
Burstmessages at once, then sustainsRatePerSecond. An honest burst (a double-click, a flurry after a reconnect) passes 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. At 10 per second, a take 0.05 s after emptying finds 0.5 tokens and blocks; at 0.1 s it finds 1.0 and passes.
Try it. This is the same arithmetic running in your browser: fire once, empty the bucket in one burst, or hold the
button and watch sustained spam settle at exactly RateLimit accepted per second.
The module has no internal clock. Callers pass now, so behavior is deterministic in tests; it defaults to
os.clock(). A take costs 58 ns in the README’s benchmark table (see Benchmarks), which is
why metering every channel is the default rather than an option.
How channels are wired
Section titled “How channels are wired”The NetDriver gate runs in this order for every inbound intent and request:
- Session: a player without an active session (not joined yet, already leaving) is dropped before
any bucket exists. A deferred message landing after
PlayerRemovingwould otherwise key a new bucket to a departed player and leak it. - Transaction lock: a profile inside a durable transaction is refused, also before the bucket.
- Token bucket: one per player per channel, created on the player’s first message to that channel with the channel’s
RateLimitandBurst. With no token the message drops,Net.RateLimitedfires as a hook and a Bus topic, andNet.Stats.IntentsRateLimitedincrements. - Only then the fail-closed hook chain and the handler.
Buckets are dropped on Kernel.SessionEnd, so nothing carries across visits.
What a rate-limited client observes:
| Channel kind | On rate limit |
|---|---|
| Intent | Silently dropped |
| Request | Answered with the channel’s RejectValue, the same encoding as a validator rejection or a timeout |
| Predicted intent | No ack. The prediction times out and rolls back, so spam burns the spammer’s own predictions |
Configuring a channel
Section titled “Configuring a channel”Net:defineIntent("UseItem", { "NumberU16" }, { RateLimit = 5, -- tokens per second per player; default 30 Burst = 10, -- bucket depth; default max(1, ceil(RateLimit)) Handler = function(session, itemId: number) applyItem(session, itemId) end,})Both values are checked at definition, so a bad limit is a boot error:
RateLimitmust be a finite number greater than 0. A non-positive or non-finite rate means a bucket that never refills.Burst, if given, must be a finite number of at least 1. An unbounded burst turns the limiter off.
The same options apply to defineRequest, definePredictedIntent and Registry definitions
(RateLimit = 5, Burst = 10 in the definition table).
The default rate is an audit finding
Section titled “The default rate is an audit finding”A channel that never sets RateLimit runs at 30 per second, and the driver remembers that it was defaulted.
kernel:securityAudit() reports every such channel with a handler as an Info finding of kind DefaultRateLimit.
Directive CK012 goes further for Game.Channels: a client-to-server channel there without RateLimit fails the
directive check.
The default of 30 per second exists to stop runaway loops and is tuned for no particular game. An ability a person
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 and audit for the full sweep.
Transport limits
Section titled “Transport limits”Channel buckets meter messages on defined channels. Below them, Packet meters everything a player sends, before any decode:
- A byte budget per heartbeat window: 8,000 bytes by default (
RateLimitBytesattribute on the Packet ModuleScript). Every remote invocation counts at least 800 bytes, so tiny-payload spam caps at 10 invocations per heartbeat. An over-budget payload drops whole. - A message budget: one token per frame from a per-player bucket, 240 per second with a burst of 128
(
RateLimitMessages,RateLimitMessageBurst). When it runs dry, the rest of the payload drops before its next frame decodes. The byte budget alone would let one 8,000-byte buffer carry thousands of empty frames. - A forged call (a payload that is not a buffer, or an instances argument that is not a table) pays the 800 bytes and drops. A flood of them reaches the byte limit too.
- A valid duplicate of a request whose handler is still running is consumed and skipped.
Packet reports each drop through its RateLimitExceeded BindableEvent as
(player, windowBytes, droppedBytes, budgetBytes, reason), with reason one of ByteLimit, MessageLimit,
DuplicateRequest or Malformed. NetDriver counts every report in Net.Stats.TransportFloods and its dropped bytes in
TransportFloodBytes, then publishes one coalesced Net.FloodDetected per player and reason on the next Scheduler step,
with the number of reports as count. Details are on the
Packet page.
The layers catch different attackers:
| Channel token bucket | Packet message budget | Packet byte budget | |
|---|---|---|---|
| Scope | Per player, per channel | Per player, all frames | Per player, all traffic |
| Unit | Messages, size-blind | Frames | Bytes, at least 800 per call |
| Window | Continuous refill | Continuous refill | Per heartbeat |
| Configured by | RateLimit and Burst per channel |
RateLimitMessages, RateLimitMessageBurst |
RateLimitBytes |
| Catches | A decodable client hammering one channel | Many small frames packed into one buffer | Raw floods, oversized payloads, garbage that never decodes |
| On exceed | One message dropped, or RejectValue |
Rest of the payload dropped | Whole payload dropped |
| Reported as | Net.RateLimited |
Net.FloodDetected (MessageLimit) |
Net.FloodDetected (ByteLimit) |
A client tripping a transport limit is a stronger signal than one tripping a channel bucket. Channel limits catch enthusiasm and bugs; byte and message floods are rarely honest.
Hooks & bus topics
Section titled “Hooks & bus topics”The hook points are declared FailOpen. They only observe: 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?, Reason: string, Count: number } |
Fail-open |
| Bus topic | Payload |
|---|---|
Net.RateLimited |
(player: Player, channelName: string), once per dropped message |
Net.FloodDetected |
(player: Player, bytes: number, payloadBytes: number, budgetBytes: number?, reason: string, count: number). bytes is the largest window total among the folded reports, payloadBytes the bytes they dropped, budgetBytes the configured budget, count the number of reports |
Net.IntentRejected (player, channelName) is a different signal. It publishes when a message passed the rate limit and
the validation chain then rejected it: probing or tampering rather than flooding. Weight it separately in a policy.
A strike policy
Section titled “A strike policy”The kernel drops; the game decides what repeated dropping means. A complete example with weighted strikes in a rolling window, transport floods weighted heavier and counted per report:
--!strict-- AbuseService: kicks players whose rate-limit and flood strikes pass a threshold.
local AbuseService = { Name = "AbuseService", Dependencies = {},}
local WindowSeconds = 30local KickThreshold = 12
local Strikes: { [Player]: { Count: number, WindowStart: number } } = {}
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
function AbuseService.init(self: any, kernel: any) self.Kernel = kernel self.Connections = { -- One channel over its rate: light weight, because lag after a freeze trips this too kernel.Bus:subscribe("Net.RateLimited", function(_, player: Player, _channelName: string) noteStrike(player, 1) end), -- Transport floods arrive coalesced; count carries how many drops were folded in kernel.Bus:subscribe("Net.FloodDetected", function(_, player: Player, _bytes, _dropped, _budget, _reason, count: number) noteStrike(player, 4 * count) end), kernel.Bus:subscribe("Kernel.SessionEnd", function(_, session) Strikes[session.Player] = nil end), }end
function AbuseService.start(_self: any) end
function AbuseService.stop(self: any) for _, Connection in self.Connections do Connection:disconnect() endend
return AbuseServiceThe split exists because an honest player on a bad connection will sometimes trip a channel limit (buffered input after a freeze, client retries). Instant punishment punishes lag. A windowed, weighted policy separates laggy from hostile, and only the 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:
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TokenBucket = require(ReplicatedStorage.ChloeKernel.Net.TokenBucket)
-- 2 chat messages per 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 when you pass the clock:
local Bucket = TokenBucket.new(10, 3, 0) -- 10 per second, burst 3, "now" is 0assert(Bucket:take(0)) -- three 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 |
bucket.RatePerSecond, bucket.Burst, bucket.Tokens, bucket.LastRefill |
Plain fields. Readable for debugging. Packet rewrites RatePerSecond and Burst live when its message-budget attributes change |
Driver counters on Net.Stats: IntentsRateLimited, TransportFloods and TransportFloodBytes, alongside
IntentsAccepted, IntentsRejected and RequestsRejected. See NetDriver.
Gotchas
Section titled “Gotchas”- Buckets are per channel. A spammer throttled on one channel starts fresh on another. The transport limits are the aggregate backstop, and the strike policy is the memory across channels.
- A clock that goes backwards is not defended: negative elapsed time skips the refill. Use
os.clock()or a monotonic test clock. - To pace a client for UX (greying out a button), mirror a bucket on the client. The server’s bucket stays the enforcement; the client copy is cosmetic.
- Before 0.9.0,
Net.FloodDetectedfired once per dropped payload with no reason. Policies that counted events should countcountinstead.