Skip to content

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.

A bucket holds up to Burst tokens and refills continuously at RatePerSecond, with no timers or 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 at once, then sustains RatePerSecond. 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 with Burst = 2 still 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.

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

  1. Session: a player without an active session (not joined yet, already leaving) is dropped before any bucket exists. A deferred message landing after PlayerRemoving would otherwise key a new bucket to a departed player and leak it.
  2. Transaction lock: a profile inside a durable transaction is refused, also before the bucket.
  3. Token bucket: one per player per channel, created on the player’s first message to that channel with the channel’s RateLimit and Burst. With no token the message drops, Net.RateLimited fires as a hook and a Bus topic, and Net.Stats.IntentsRateLimited increments.
  4. 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
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:

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

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.

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 (RateLimitBytes attribute 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.

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.

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:

src/Server/Game/Services/AbuseService.luau
--!strict
-- AbuseService: kicks players whose rate-limit and flood strikes pass a threshold.
local AbuseService = {
Name = "AbuseService",
Dependencies = {},
}
local WindowSeconds = 30
local 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")
end
end
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()
end
end
return AbuseService

The 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.

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 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 when you pass the clock:

local Bucket = TokenBucket.new(10, 3, 0) -- 10 per second, burst 3, "now" is 0
assert(Bucket:take(0)) -- three 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
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.

  • 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.FloodDetected fired once per dropped payload with no reason. Policies that counted events should count count instead.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.