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.
Mental model
Section titled “Mental model”Worst axis wins
Section titled “Worst axis wins”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 1local 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%) |
Ping and jitter
Section titled “Ping and jitter”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.
Loss: sequence-numbered probes
Section titled “Loss: sequence-numbered probes”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 Hzlocal Cutoff = self.Clock() - Gracelocal Counted, Lost = 0, 0for _, Probe in state.Probes do if Probe.At <= Cutoff then Counted += 1 if not state.Acked[Probe.Seq] then Lost += 1 end endendif Counted < 4 then return nil -- too few aged probes to gradeendreturn Lost / CountedTwo 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.
Sampling only while needed
Section titled “Sampling only while needed”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.
The DeviceBench hint
Section titled “The DeviceBench hint”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.
How a tier change reaches Replica
Section titled “How a tier change reaches Replica”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 == 0endThe 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.
Shedding unreliable traffic
Section titled “Shedding unreliable traffic”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 falseendA 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.
Composition
Section titled “Composition”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.
Per-player state
Section titled “Per-player state”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.
Tuning for a genre
Section titled “Tuning for a genre”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
ProbeHzto 8 halves the time it takes 4 aged probes to accumulate after a spike, for twice the probe traffic (anNG_Probedatagram is a few bytes). - For mobile-heavy audiences, move
DeviceStrainQualitycautiously. 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
PingEveryto 2 or 3 seconds and widenWindowandLossWindowto match. - Poor divisors above 4 are fine for high-
TickRatereplicas: a divisor of 8 on a 20 Hz replica still delivers 2.5 Hz.
--!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 LinkServiceReplica 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) })--!strict-- LinkController: answers the server's link probes.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local GovernorClient = require(ReplicatedStorage.ChloeKernel.Net.GovernorClient)
local LinkController = { Name = "LinkController", Dependencies = {},}
function LinkController.init(self: any, kernel: any) self.Kernel = kernelend
function LinkController.start(self: any) -- Yields until NG_Probe exists (up to 10 s), then reports readiness and the device hint once self.Governor = GovernorClient.attach()end
function LinkController.stop(self: any) self.Governor.detach() -- disconnects the probe echo; NG_Ready and NG_Device have nothing to undoend
return LinkControllerAPI reference
Section titled “API reference”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 |
Thresholds
Section titled “Thresholds”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 |
Governor instance
Section titled “Governor instance”| 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 |
Pure functions
Section titled “Pure functions”| 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 (client)
Section titled “GovernorClient (client)”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 |
Wire channels
Section titled “Wire channels”| 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.
Bus topics
Section titled “Bus topics”| 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).