Skip to content

NetGovernor

Grades each client's link Good, Strained or Poor from ping, jitter, measured unreliable loss and a device hint, so Replica slows deltas and unreliable channels shed cosmetic traffic for bad connections.

Realm
Server + Client
Require
ServerScriptService.ChloeKernelServer.NetGovernor
Since
v0.6.0
Source
NetGovernor.luauNet/GovernorClient.luau

NetGovernor watches each connected player’s link and grades it Good, Strained or Poor. Downstream systems read the grade instead of guessing: Replica sends deltas to a struggling client less often instead of burying the connection in full-rate updates, and Low-priority unreliable channels stop sending cosmetic traffic to Poor links. A player grades no better than their single worst signal, because a connection with good ping that loses a fifth of its packets is still a bad connection.

The signals are ping median and jitter (sampled from the engine), unreliable packet loss (measured with the governor’s own probe and echo, since Roblox does not expose it) and an optional DeviceBench quality hint from the client. None of it touches authority. A bad grade changes how often a client hears about state changes, never what the state is or whether the server trusts its input.

NetGovernor.evaluate(stats, thresholds?) is the pure grading function. It grades ping, jitter and loss separately against a Strained and a Poor threshold, takes the worst, and only then applies the device hint:

-- graded(value, strained, poor): nil -> 1, >= poor -> 3, >= strained -> 2, else 1
local Rank = math.max(
graded(stats.PingMs, Limits.StrainedPingMs, Limits.PoorPingMs),
graded(stats.JitterMs, Limits.StrainedJitterMs, Limits.PoorJitterMs),
graded(stats.Loss, Limits.StrainedLoss, Limits.PoorLoss)
)
if stats.DeviceQuality ~= nil and stats.DeviceQuality <= (stats.DeviceStrainQuality or 0.5) then
Rank = math.max(Rank, 2)
end
-- Rank 3 is "Poor", 2 is "Strained", 1 is "Good"

An axis with no data (nil) grades as rank 1: it never demotes a player. A new joiner with no loss samples yet grades on ping and jitter alone instead of starting at Poor for lack of data.

Default thresholds:

Axis Strained at Poor at
Ping 120 ms 250 ms
Jitter 40 ms 100 ms
Loss 0.08 (8%) 0.2 (20%)

Every PingEvery seconds (default 1), sample() reads player:GetNetworkPing() (or an injected PingProvider) for each tracked player and pushes the result, in milliseconds, into that player’s window. The window keeps the last Window samples (default 10), so at the default cadence it covers about 10 seconds. A provider that errors or returns NaN adds no sample.

NetGovernor.pingStats(samples) reduces the window to two numbers:

  • Median: the middle of the sorted window (Sorted[math.ceil(#Sorted / 2)]). One spike does not move it the way it moves a mean.
  • Jitter: the mean absolute deviation around that median. A steady 150 ms link and one swinging between 50 and 250 ms can share a median; jitter tells them apart.

An empty window returns nil, nil.

Roblox does not expose unreliable loss, so the governor measures it. Every 1 / ProbeHz seconds (default 4 Hz, every 0.25 s) probe() sends an NG_Probe datagram with a NumberU16 sequence to every ready tracked player, and records { Seq, At } in that player’s probe list, capped at LossWindow entries (default 40, about 10 seconds at the default rate). The probe’s sequence is marked outstanding. GovernorClient echoes every probe it receives over the NG_Ack intent, and the server marks the sequence acked, but only if it is still outstanding. A client cannot pre-ack future sequences or grow the table.

lossOf(state) counts only probes old enough that an honest ack would have arrived. A probe sent 50 ms ago is still in flight:

local Grace = 2 / self.ProbeHz -- two probe intervals: 0.5 s at 4 Hz
local Cutoff = self.Clock() - Grace
local Counted, Lost = 0, 0
for _, Probe in state.Probes do
if Probe.At <= Cutoff then
Counted += 1
if not state.Acked[Probe.Seq] then
Lost += 1
end
end
end
if Counted < 4 then
return nil -- too few aged probes to grade
end
return Lost / Counted

Two channel options keep the measurement honest. NG_Probe is High priority, so it is never shed (see Shedding). It is also Ordered = false: a probe that arrives behind a newer one still landed and must be echoed, or reordering would read as loss.

probe() skips a tracked player whose Ready flag is false. A player becomes ready when their NG_Ready intent arrives, which GovernorClient sends after it connects the probe listener. Without that handshake, probes sent during the join window, before the client’s script runs, would reach no listener and read as loss on a healthy link. Ping sampling needs no such gate, because GetNetworkPing() needs nothing from the client.

The ping job runs while any session is tracked, and the probe job runs while any tracked client is ready. Both run on the kernel Scheduler at Background priority and are armed and cancelled as sessions start, become ready and end. An empty server has no governor wakeups. Before 0.9.0 both jobs ran from attach until detach.

Once per session, GovernorClient.attach() reports the client’s DeviceBench quality over the NG_Device intent (one NumberF32). The server rejects NaN, clamps the value to 0.25 to 4 (DeviceBench’s own band) and stores the first report only.

In evaluate, a device quality at or below DeviceStrainQuality (default 0.5) floors the tier at Strained, never Poor. A low-end phone on a good connection gets a lighter replication cadence, since it is already spending its frame budget on rendering, but it is not treated like a lossy link and does not lose unreliable traffic. Only ping, jitter and loss can reach Poor.

evaluateAll() runs on the ping cadence. For each tracked player it fills one stats table (PingMs, JitterMs, Loss, DeviceQuality, DeviceStrainQuality), grades it and, only when the tier changed, increments Stats.TierChanges, updates the Poor count and publishes Net.TierChanged. The stats table is reused for the player’s whole session and overwritten in place, so copy the fields you want to keep.

Replica reads Governor:divisorFor(player) when it delivers: Divisors[tier], default { Good = 1, Strained = 2, Poor = 4 }. Divisor 1 sends every delivery. A Strained subscriber gets every second delivery and a Poor one every fourth. The gate is ReplicaService.onCadence:

function ReplicaService.onCadence(tickCount, divisor, player)
if divisor <= 1 then
return true
end
local Seed = if typeof(player) == "Instance" or type(player) == "table" then ((player :: any).UserId or 0) else 0
return (tickCount + Seed) % divisor == 0
end

The UserId seed staggers players across the cycle. Without it, every Poor subscriber would land on the same fourth delivery and their sends would burst together; with it they spread across the divisor’s remainders.

Skipped fields are banked. On an off-cadence delivery, Replica merges the dirty fields into that subscriber’s owed table. On their next on-cadence delivery the fresh dirty values are merged over the owed ones (the latest value wins) and sent as one delta. A replica with owed fields stays scheduled at its own TickRate even when nothing new is written, so a field that changed three times while a Poor client was off-cadence arrives once, with its latest value, at most divisor - 1 deliveries late.

Snapshots and removes are never gated by tier. A Poor client still gets a full snapshot the moment it becomes interested and a remove the moment it stops.

Unreliable channels declare Priority = "Low" (the default) or "High", either as the third argument to Net:defineUnreliableState or as a field on an Unreliable definition in Game.Channels, which Registry.server passes through. Before 0.9.1 the Registry dropped the field, so every Registry unreliable channel shed. Every send checks Governor:shouldShed(player, priority) first:

function NetGovernor.shouldShed(self, player, priority)
if priority == "High" then
return false
end
local State = self.Tracked[player]
if State and State.Tier == "Poor" then
self.Stats.ShedPackets += 1
return true
end
return false
end

A shed send never reaches encoding or the wire; it only counts. A Poor client’s bandwidth goes to reliable traffic (intents, requests, replica deltas) instead of effects nobody consciously misses. poorCount() tracks how many players are Poor, and while it is 0 a broadcast goes out in one FireAllClients instead of a per-player loop.

NG_Probe must be High. If it were Low, it would shed to exactly the Poor clients the governor most needs fresh data from. The server records a probe in State.Probes before the send, so a shed probe would age into a counted, unacked sample. Loss would read as total, total loss grades Poor, and a Poor player could never recover.

NetGovernor.attach(kernel, options?) stores the governor at kernel.NetGovernor. Replica and NetDriver read that field at send time, so attach order relative to Replicas:create or Net:defineUnreliableState does not matter. What matters is attaching before any client runs GovernorClient.attach(): the NG_* channels exist only once the server defines them.

detach() disconnects the session listener, cancels both jobs, clears tracked state and the Poor count, and clears kernel.NetGovernor if it still points at this instance, so a stale detach cannot clear a governor that replaced it. Before 0.9.0 the session listener stayed connected after detach.

kernel:onSession gives every session a fresh tracking table and session:bind removes it when the session ends, so the governor needs no PlayerRemoving sweep:

Field Holds
Pings Rolling ping window in ms, at most Window long
Probes { Seq, At } entries for recent probes, at most LossWindow long
Acked { [Seq]: boolean }. false while outstanding, true once echoed, removed when the probe leaves Probes
NextSeq Last probe sequence sent, wrapping at 65,536
Ready Whether NG_Ready arrived for this session. Gates probe()
Tier Last graded tier, compared on each evaluateAll()
DeviceQuality nil until NG_Device reports, then fixed
LastStats The stats table statsOf() returns, reused in place

A player who rejoins gets a new session and a fresh table; no grade or probe history carries over.

The defaults (1 s evaluation, 4 Hz probes, a 10-sample ping window, a 40-probe loss window) suit a generic third-person or top-down game:

  • Fast shooters care about loss sooner. Raising ProbeHz to 8 halves the time it takes 4 aged probes to accumulate after a spike, for twice the probe traffic (an NG_Probe datagram is a few bytes).
  • For mobile-heavy audiences, move DeviceStrainQuality cautiously. Raising it puts more devices at Strained by default; combined with tighter ping and jitter thresholds, most of the player base can end up Poor.
  • Turn-based or slow games can push PingEvery to 2 or 3 seconds and widen Window and LossWindow to match.
  • Poor divisors above 4 are fine for high-TickRate replicas: a divisor of 8 on a 20 Hz replica still delivers 2.5 Hz.
src/Server/Game/Services/LinkService.luau
--!strict
-- LinkService: grades client links and logs tier changes.
local ServerScriptService = game:GetService("ServerScriptService")
local NetGovernor = require(ServerScriptService.ChloeKernelServer.NetGovernor)
local LinkService = {
Name = "LinkService",
Dependencies = {},
}
function LinkService.init(self: any, kernel: any)
self.Kernel = kernel
-- Defines NG_Probe, NG_Ready, NG_Ack and NG_Device before any client attaches
self.Governor = NetGovernor.attach(kernel, {
Thresholds = { StrainedPingMs = 100, PoorPingMs = 220 },
Divisors = { Strained = 2, Poor = 4 },
})
self.TierConnection = kernel.Bus:subscribe("Net.TierChanged", function(_, player: Player, tier: string, stats)
print(`{player.Name} is {tier} (ping {stats.PingMs}, loss {stats.Loss})`)
end)
end
function LinkService.start(_self: any) end
function LinkService.stop(self: any)
self.TierConnection:disconnect()
self.Governor:detach()
end
return LinkService

Replica needs no wiring: it reads kernel.NetGovernor itself. A Low unreliable channel sheds to Poor links on its own:

local Net = Kernel:net()
Net:defineUnreliableState("Fx_Muzzle", { Position = "Vector3F24" }, { Priority = "Low" })
Net:broadcastUnreliableState("Fx_Muzzle", { Position = Vector3.new(0, 5, 0) })

NetGovernor.attach(kernel, options?) -> Governor

Section titled “NetGovernor.attach(kernel, options?) -> Governor”
Option Type Default Description
PingEvery number? 1 Seconds between ping samples and tier evaluations
ProbeHz number? 4 Probes per second to each ready player
Window number? 10 Ping samples kept per player
LossWindow number? 40 Probes kept per player
Thresholds Thresholds? below Strained and Poor cutoffs per axis
Divisors { Good?, Strained?, Poor? }? 1, 2, 4 Replica delivery divisor per tier
DeviceStrainQuality number? 0.5 A device quality at or below this floors the tier at Strained
PingProvider ((player) -> number)? player:GetNetworkPing() Seconds. Override for specs or another ping source
Net any? kernel:net() Injected driver, a test seam
Clock (() -> number)? os.clock Injected clock, a test seam
SkipLoops boolean? false No Scheduler jobs; specs call sample(), probe() and evaluateAll() themselves

A partial table is fine: every field left out uses its default.

Field Type Default Unit
StrainedPingMs number? 120 ms
PoorPingMs number? 250 ms
StrainedJitterMs number? 40 ms
PoorJitterMs number? 100 ms
StrainedLoss number? 0.08 0 to 1
PoorLoss number? 0.2 0 to 1
Member Description
Governor:tierOf(player) -> string "Good", "Strained" or "Poor". Untracked players (no session) read "Good"
Governor:statsOf(player) -> stats? The player’s reused stats table (PingMs, JitterMs, Loss, DeviceQuality, DeviceStrainQuality), or nil before the first evaluation or when untracked
Governor:divisorFor(player) -> number Replica delivery divisor for the player’s tier. 1 if untracked
Governor:shouldShed(player, priority?) -> boolean true for a Low or unset priority send to a Poor player; always false for "High". Increments Stats.ShedPackets when it returns true
Governor:poorCount() -> number Tracked players graded Poor right now. NetDriver uses one FireAllClients while it is 0
Governor:sample(), Governor:probe(), Governor:evaluateAll() One pass of each job. The Scheduler calls them; specs with SkipLoops call them directly
Governor:detach() Disconnects the session listener, cancels both jobs, clears tracked state, and clears kernel.NetGovernor if it still points here
Governor.Stats { ShedPackets: number, TierChanges: number }, lifetime counters across all players
Governor.Tracked { [Player]: state }. Internal; read it through the methods above outside of specs
Member Description
NetGovernor.evaluate(stats, thresholds?) -> string Grades one stats table by the worst axis, then applies the device floor
NetGovernor.pingStats(samples) -> (number?, number?) Median and mean absolute deviation in ms; nil, nil for an empty array

GovernorClient.attach(options?) -> { detach() } connects the NG_Probe echo first, then spawns a thread that fires NG_Ready and the device report. The echo listener is therefore live before the server can mark the client ready and start probing. attach yields until the NG_Probe remote exists and errors after 10 seconds if the server never attached a governor.

Option Type Default Description
ReportDevice boolean? true Send the DeviceBench quality hint over NG_Device
Bench any? a fresh DeviceBench.run() Injected bench result, passed to DeviceBench.quality(Bench). A test seam
Member Description
handle.detach() Disconnects the probe echo. NG_Ready and NG_Device already fired once and have nothing to undo
Channel Kind Schema Options
NG_Probe Unreliable state { Seq = "NumberU16" } Priority = "High" (never shed), Ordered = false
NG_Ready Intent none Open = true, RateLimit = 1
NG_Ack Intent { "NumberU16" } Open = true, RateLimit = ProbeHz * 3 (12 per second by default)
NG_Device Intent { "NumberF32" } Open = true, RateLimit = 1

The three intents are Open. None grants anything beyond tuning the sender’s own cadence and probe accounting, so they need no validator under the fail-closed rule, and securityAudit() lists them as expected OpenChannel findings. NG_Probe and NG_Ack are muted in the NetTap debug panel on both sides, so the 4 Hz probe traffic does not drown out the NET window.

The rate limits fit the traffic. NG_Ready and NG_Device fire once per session, so 1 per second is headroom. NG_Ack scales with ProbeHz because an honest client echoes one ack per probe; three times the probe rate leaves room for a bursty echo. Changing ProbeHz moves that limit with it.

Topic Payload When
Net.TierChanged (player, tier, stats) From evaluateAll(), only when a player’s tier differs from their last one. stats is the reused table statsOf returns; read it in the handler or copy the fields

Bus:subscribe handlers receive the topic name first: function(topic, player, tier, stats).

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