NetGovernor
NetGovernor watches every connected player’s actual link quality and grades them Good, Strained, or Poor. Nothing downstream has to guess: Replica reads the grade to slow delta cadence for a struggling client instead of burying their connection in full-rate updates, and unreliable state channels read it to shed cosmetic traffic before it fights for bytes with structural sends. The defining decision is worst-axis-wins — a player grades no better than their single worst signal, because a connection that’s fine on ping but hemorrhaging packets is not fine.
The three signals are ping median plus jitter (sampled from the engine), unreliable packet loss (measured with the kernel’s own probe/ack exchange, not inferred from anything Roblox exposes), and an optional client-reported DeviceBench quality hint. None of this touches server authority — a bad grade changes how often a client hears about state changes, never what the state is or whether the server trusts their input.
Mental model
Section titled “Mental model”Worst axis wins
Section titled “Worst axis wins”NetGovernor.evaluate(stats, thresholds?) is the pure grading function everything else calls into. It grades ping, jitter, and loss independently against a strained/poor threshold pair, takes the worst of the three, and only then folds in the device hint:
function NetGovernor.evaluate(stats, thresholds) -- graded(value, strained, poor): nil -> 1 (ungraded axes never demote), -- >= 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 -> "Poor", Rank >= 2 -> "Strained", else "Good"endAn axis with no data (nil) grades as rank 1 — it never demotes a player, it just doesn’t help them either. This is why a brand-new joiner (no loss samples yet, ping already sampling) starts at whatever their ping/jitter alone earn, not automatically Poor for lack of data.
Default thresholds, exact from source:
| Axis | Strained at | Poor at |
|---|---|---|
| Ping | 120ms |
250ms |
| Jitter | 40ms |
100ms |
| Loss | 0.08 (8%) |
0.2 (20%) |
Ping and jitter: median and mean absolute deviation over a rolling window
Section titled “Ping and jitter: median and mean absolute deviation over a rolling window”Every PingEvery seconds (default 1), sample() reads player:GetNetworkPing() (or an injected PingProvider) for every tracked player and pushes one sample, in milliseconds, into that player’s ping window. The window holds the last Window samples (default 10) — oldest drops when a new one arrives, so at the default cadence this is roughly the last 10 seconds of ping.
NetGovernor.pingStats(samples) reduces that window to two numbers:
- Median — sort the window, take the middle element (
Sorted[math.ceil(#Sorted / 2)]). Immune to a single spike the way a mean isn’t. - Jitter — mean absolute deviation around that median:
sum(abs(sample - median)) / count. A connection with stable 150ms ping and one with ping swinging 50-250ms can share a median; jitter is what tells them apart.
Both read nil until at least one sample exists, and an empty window returns nil, nil.
Loss: sequence-numbered probes, and why the NG_Ready handshake exists
Section titled “Loss: sequence-numbered probes, and why the NG_Ready handshake exists”Roblox doesn’t expose unreliable packet loss, so NetGovernor measures its own: every 1 / ProbeHz seconds (default 4 Hz → every 0.25s) probe() sends an unreliable NG_Probe packet carrying an incrementing NumberU16 sequence number to every ready tracked player, and records { Seq, At = now } in that player’s probe list, capped at LossWindow entries (default 40, ≈10 seconds of probe history at the default rate). GovernorClient echoes every NG_Probe it receives straight back over the NG_Ack intent; the server’s ack handler flips Acked[seq] = true.
lossOf(state) computes the actual loss fraction, but only over probes old enough that a legitimate ack would already have arrived — a probe sent 50ms ago hasn’t been “lost” yet, it’s in flight:
function NetGovernor.lossOf(self, state) local Grace = 2 / self.ProbeHz -- two probe intervals: 0.5s at the default 4Hz 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 -- not enough landed probes to grade end return Lost / CountedendFewer than 4 aged-out probes and loss reads nil (ungraded, rank 1) rather than a noisy ratio from one or two samples.
Why NG_Ready exists at all. probe() skips any tracked player whose State.Ready is still false — no probe is ever sent to them, full stop. A player is only marked ready when their NG_Ready intent handler fires, which GovernorClient.attach() does immediately on the client, before it ever risks losing a probe to a listener that doesn’t exist yet. Without this gate, the handful of probes sent during the join window — before the client’s script has even run — would arrive at a socket with no OnClientEvent listener, get silently dropped, and read as real loss on a connection that’s actually fine. The handshake makes “not attached yet” and “attached but lossy” distinguishable; ping/jitter sampling has no such gate because GetNetworkPing() needs no client cooperation.
The DeviceBench hint: a floor, never the whole verdict
Section titled “The DeviceBench hint: a floor, never the whole verdict”Once per session, GovernorClient.attach() reports the client’s DeviceBench quality score over the NG_Device intent (a single NumberF32). The server clamps it to 0.25..4 (DeviceBench’s own output band) and stores it once — a repeat report is ignored (State.DeviceQuality == nil gates the write):
Handler = function(session, quality) local State = Instance.Tracked[session.Player] if State and State.DeviceQuality == nil and quality == quality then -- reject NaN State.DeviceQuality = math.clamp(quality, 0.25, 4) endendIn evaluate, a device quality at or below DeviceStrainQuality (default 0.5) floors the tier at Strained — Rank = math.max(Rank, 2), never 3. A weak device can never push a player to Poor by itself, no matter how low the quality score: a low-end phone with a great connection should get lighter replication cadence (it’s already spending its frame budget on rendering), but it shouldn’t get treated like a lossy, laggy link and start dropping unreliable cosmetic traffic that has nothing to do with its actual pipe. Ping, jitter, and loss are the only axes that can reach Poor.
How a tier change reaches Replica: divisors, phase stagger, and banked fields
Section titled “How a tier change reaches Replica: divisors, phase stagger, and banked fields”evaluateAll() runs on the same PingEvery cadence as ping sampling. For each tracked player it rebuilds one stats table (PingMs, JitterMs, Loss, DeviceQuality, DeviceStrainQuality), re-evaluates the tier, and — only on an actual change — bumps Governor.Stats.TierChanges and publishes Net.TierChanged. The per-player stats table is reused in place across evaluations (overwritten field-by-field, not reallocated); if you hold onto a stats reference from a bus handler across ticks, snapshot the fields you need — the table’s contents move on.
Replica reads the current tier through Governor:divisorFor(player), which returns Divisors[tier] — default { Good = 1, Strained = 2, Poor = 4 }. A divisor of 1 means “send every tick, as if there were no governor”; a Strained client’s replicas send deltas on every 2nd tick, Poor on every 4th.
The gate itself lives in ReplicaService.onCadence(tickCount, divisor, player):
function ReplicaService.onCadence(tickCount, divisor, player) if divisor <= 1 then return true end local Seed = (player :: any).UserId or 0 return (tickCount + Seed) % divisor == 0endThe UserId-derived Seed is the per-player phase stagger. Without it, every Poor-tier subscriber of a replica would land on the exact same “every 4th tick,” so all their delta sends would burst together on that one tick and go quiet for three — a spiky send pattern that’s worse for the server’s frame budget than the same total traffic spread out. With the stagger, different players’ on-cadence ticks fall on different remainders of the divisor, so degraded clients’ sends distribute across the cycle instead of piling up.
Skipped fields bank, they never vanish. On an off-cadence tick, Replica doesn’t just skip the subscriber — it merges the tick’s dirty fields into a per-subscriber Owed table (creating it on first skip), keyed the same as any delta. On the next on-cadence tick, any pending Owed fields are merged under the fresh dirty values (Pending[Key] = Value per current-tick field, so the latest value always wins) and the merged table ships as that subscriber’s delta; Owed clears. A field that changed three times while a Poor client was off-cadence sends once, with its latest value — never lost, only delayed by up to divisor - 1 ticks of that replica’s own cadence. This is coalescing, the same principle as Replica’s normal same-tick write coalescing, just stretched across a client’s degraded cadence instead of one tick.
Snapshots and removals are never gated by tier. The interest scan that fires CKRep_Create (join) and CKRep_Remove (leave/interest-loss) runs unconditionally every replica tick for every player, regardless of divisorFor. Only the delta fan-out for already-subscribed players is paced by divisor — a Poor-tier client still gets a full, immediate snapshot on join and an immediate remove on interest loss.
Shedding unreliable traffic: Low sheds to Poor, High never does
Section titled “Shedding unreliable traffic: Low sheds to Poor, High never does”Unreliable state channels declare a priority: Net:defineUnreliableState(name, schema, { Priority = "Low" | "High" }), defaulting to "Low". Every send — sendUnreliableState (to one player) and broadcastUnreliableState (to all, per-recipient once a governor is attached) — checks Governor:shouldShed(player, channel.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 packet never reaches Serde encoding or the wire — the send call returns having done nothing but count the shed. This is the right tradeoff for cosmetic streams (impact sparks, footstep dust, ambient particle triggers): a Poor-tier client’s scarce bandwidth goes to reliable structural traffic (intents, requests, replica deltas) instead of competing with effects nobody will consciously miss.
NG_Probe itself is declared Priority = "High" — this is load-bearing, not incidental. If the probe channel were Low like any other cosmetic stream, it would shed to exactly the clients whose tier is Poor — the ones the governor most needs fresh signal from to know if they’ve recovered. A shed probe still gets recorded in State.Probes (the bookkeeping happens before the send-layer shed check runs), but it’s never actually transmitted, so it’s never echoed, so it never gets acked — every shed probe ages into a counted, unacked sample the moment it clears the grace window. Loss would read as total loss forever, and total loss is graded Poor, so a Poor client would never be re-evaluated as anything else — the tier would lock permanently with no path back to Good even if the underlying connection genuinely improved. Keeping the probe channel High-priority is what makes the grade self-correcting in both directions.
Composition: how other systems find the governor
Section titled “Composition: how other systems find the governor”NetGovernor.attach(kernel, options?) stores itself at kernel.NetGovernor. Replica and NetDriver both read that field live, at send time, not at replica-creation or channel-definition time — so attach order relative to Replicas:create(...) or Net:defineUnreliableState(...) doesn’t matter. What does matter is attaching before any client can run GovernorClient.attach(): the NG_Probe/NG_Ready/NG_Ack/NG_Device channels only exist once the server side has defined them, same as any other kernel channel (see the “server defines first” note on Packet & Wire Types). detach() cancels the two scheduler loops, clears tracked state, and clears kernel.NetGovernor back to nil — but only if it’s still pointing at this instance, so a stale detach can’t clobber a governor that replaced it.
Per-player state and session cleanup
Section titled “Per-player state and session cleanup”kernel:onSession(...) gives every session a fresh tracking table the moment it starts, and session:bind(...) tears it down the moment the session ends — the governor never needs its own PlayerRemoving sweep:
| Field | Holds |
|---|---|
Pings |
Rolling ping window in ms, length-capped at Window |
Probes |
{ Seq, At } entries for in-flight/recent probes, length-capped at LossWindow |
Acked |
{ [Seq]: true } — set by the NG_Ack handler, pruned when its probe ages out of Probes |
NextSeq |
Next probe sequence number, wrapping at 65536 (NumberU16 range) |
Ready |
Whether NG_Ready has fired for this session; gates probe() |
Tier |
Last graded tier — compared against each evaluateAll() result to detect a change |
DeviceQuality |
nil until NG_Device reports once; write-once thereafter |
LastStats |
The reused-in-place stats table statsOf() returns |
A player who reconnects gets an entirely new session and a fresh table — nothing about a previous connection’s grade or probe history survives a rejoin.
Tuning for a genre
Section titled “Tuning for a genre”The defaults (1s evaluation, 4Hz probes, 10-sample ping window, 40-probe loss window) are tuned for a generic third-person or top-down game. A few knobs worth revisiting deliberately:
- Fast-twitch shooters care about loss more than the defaults assume — raising
ProbeHz(say to 8) halves the time it takes 4 aged-out samples to accumulate after a spike, so the loss axis reacts faster at the cost of double the probe traffic (still trivial: aNG_Probepayload is a few bytes). - Mobile-heavy audiences should lower
DeviceStrainQualitycautiously, if at all — raising it makes more devices float at Strained by default, which is the point of the floor, but pairs badly with also tightening ping/jitter thresholds; the two shouldn’t both move stricter at once or most of the playerbase strands at Poor. - Turn-based or slow-tick games can push
PingEveryout (2-3s) and loosenWindow/LossWindowcorrespondingly — reacting to a link within one second matters far less when the replicas themselves only tick a few times a second anyway. - Divisors past
4for Poor are legitimate if a game’s replicas are already high-TickRate— an 8-divisor on a 20Hz replica still delivers 2.5Hz to a Poor client, which may be plenty for a scoreboard.
local ServerScriptService = game:GetService("ServerScriptService")local NetGovernor = require(ServerScriptService.ChloeKernelServer.NetGovernor)local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)
-- Attach before Bootstrap lets any client run GovernorClient.attach() —-- it defines the NG_Probe/NG_Ready/NG_Ack/NG_Device channels the client half needs.local Governor = NetGovernor.attach(Kernel, { Thresholds = { StrainedPingMs = 100, PoorPingMs = 220, }, Divisors = { Strained = 2, Poor = 4 }, -- defaults; explicit for clarity})
Kernel.Bus:subscribe("Net.TierChanged", function(topic, player, tier, stats) print(`{player.Name} -> {tier} (ping {stats.PingMs}, loss {stats.Loss})`)end)
-- Replica needs no extra wiring: it reads Kernel.NetGovernor itself and-- staggers Strained/Poor subscribers automatically.local Replicas = ReplicaService.new(Kernel)local Match = Replicas:create("Match", { Schema = { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }, Data = { TimeLeft = 300, RedScore = 0, BlueScore = 0 }, TickRate = 10,})
-- Cosmetic unreliable stream: sheds to Poor-tier links automaticallylocal Net = Kernel:net()Net:defineUnreliableState("Fx_Muzzle", { Position = "Vector3F24" }, { Priority = "Low" })
Match:set("RedScore", Match:get("RedScore") + 1)Net:broadcastUnreliableState("Fx_Muzzle", { Position = Vector3.new(0, 5, 0) })local ReplicatedStorage = game:GetService("ReplicatedStorage")local GovernorClient = require(ReplicatedStorage.ChloeKernel.Net.GovernorClient)local ReplicaClient = require(ReplicatedStorage.ChloeKernel.Net.ReplicaClient)
-- Reports the DeviceBench hint once and starts echoing NG_Probe.local Governor = GovernorClient.attach()
local Replicas = ReplicaClient.new()Replicas:listen("Match", { TimeLeft = "NumberU16", RedScore = "NumberU8", BlueScore = "NumberU8" }, function(match) Scoreboard.update(match.Data)end)
-- Governor.detach() only disconnects the NG_Probe echo; NG_Ready and-- NG_Device have already fired and don't need to be undone.API 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 re-evaluation |
ProbeHz |
number? | 4 |
Unreliable probes sent per ready player per second |
Window |
number? | 10 |
Ping samples kept per player (rolling) |
LossWindow |
number? | 40 |
Probe sequences kept per player (rolling) |
Thresholds |
table? | see below | Per-axis Strained/Poor cutoffs |
Divisors |
{ Good?, Strained?, Poor? } |
{ 1, 2, 4 } |
Replica delta cadence divisor per tier |
DeviceStrainQuality |
number? | 0.5 |
Device quality at or below this floors the tier at Strained |
PingProvider |
(player) → number? |
player:GetNetworkPing() |
Seconds; override for specs or a custom ping source |
Net |
any? | kernel:net() |
Injected driver, test seam |
Clock |
() → number? |
os.clock |
Injected clock, test seam |
SkipLoops |
boolean? | false |
Skip the two Scheduler loops; specs call sample()/probe()/evaluateAll() manually |
Thresholds
Section titled “Thresholds”| 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..1 |
PoorLoss |
number? | 0.2 |
0..1 |
Governor instance
Section titled “Governor instance”| Member | Description |
|---|---|
Governor:tierOf(player) → tier |
"Good", "Strained", or "Poor". Untracked players (no session) read "Good" |
Governor:statsOf(player) → stats? |
The live, reused-in-place stats table (PingMs, JitterMs, Loss, DeviceQuality, DeviceStrainQuality), or nil if untracked |
Governor:divisorFor(player) → number |
Replica delta cadence divisor for the player’s current tier. 1 if untracked |
Governor:shouldShed(player, priority?) → boolean |
true for a Low/unset-priority send to a Poor-tier player; always false for "High". Increments Stats.ShedPackets when it returns true |
Governor:detach() |
Cancels both Scheduler loops, clears tracked state, clears kernel.NetGovernor if it still points here |
Governor.Stats |
{ ShedPackets: number, TierChanges: number } — lifetime counters across every tracked player |
Governor.Tracked |
{ [Player]: state } — internal per-player state; read via the methods above, not directly, outside of specs |
Pure functions (no attach required)
Section titled “Pure functions (no attach required)”| Member | Description |
|---|---|
NetGovernor.evaluate(stats, thresholds?) → tier |
Grades one stats table by the worst axis, then applies the device floor |
NetGovernor.pingStats(samples) → (median?, jitter?) |
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 the readiness and device-report sequence — so the echo listener is guaranteed live before the server can mark this client Ready and start sending real probes.
| Option | Type | Default | Description |
|---|---|---|---|
ReportDevice |
boolean? | true |
Send the DeviceBench quality hint over NG_Device |
Bench |
any? | DeviceBench.run() |
Injected bench result, test seam — passed to DeviceBench.quality(Bench) |
| Return | Description |
|---|---|
detach() |
Disconnects the NG_Probe echo connection. NG_Ready and NG_Device already fired once and have nothing to undo |
Wire channels
Section titled “Wire channels”| Channel | Kind | Schema | Priority / Options |
|---|---|---|---|
NG_Probe |
Unreliable state | { Seq = "NumberU16" } |
Priority = "High" — never sheds |
NG_Ready |
Intent | {} (no args) |
Open = true, RateLimit = 1 |
NG_Ack |
Intent | { "NumberU16" } |
Open = true, RateLimit = ProbeHz * 3 (default 12/s) |
NG_Device |
Intent | { "NumberF32" } |
Open = true, RateLimit = 1 |
All four are Open — none grant anything but tuning this player’s own cadence and probe accounting, so no validator is needed for the fail-closed absence rule (see NetDriver). All four are muted from the NetTap debug panel’s live-wire ring on both sides, so the ~4Hz probe heartbeat doesn’t drown out real traffic in the NET window.
The rate limits are sized to their traffic shape, not left at the driver’s default: NG_Ready and NG_Device each fire exactly once per session, so RateLimit = 1 is generous headroom, not a real ceiling. NG_Ack scales with ProbeHz (ProbeHz * 3) because a client legitimately echoes one ack per probe it receives — tripling the probe rate gives slack for a retried or slightly bursty echo without opening the channel to a client that fires acks the governor never sent probes for. Changing ProbeHz on NetGovernor.attach moves this ceiling automatically; there’s no separate NG_Ack rate option to keep in sync.
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Net.TierChanged |
(player, tier, stats) |
Published from evaluateAll() only when a player’s computed tier differs from their last known tier. stats is the same reused-in-place table statsOf returns — read it synchronously in the handler or copy the fields you need |
Handlers registered through Kernel.Bus:subscribe receive the topic name prepended: function(topic, player, tier, stats).