Skip to content

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.

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"
end

An 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 / Counted
end

Fewer 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)
end
end

In evaluate, a device quality at or below DeviceStrainQuality (default 0.5) floors the tier at StrainedRank = 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 == 0
end

The 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 false
end

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

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.

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: a NG_Probe payload is a few bytes).
  • Mobile-heavy audiences should lower DeviceStrainQuality cautiously, 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 PingEvery out (2-3s) and loosen Window/LossWindow correspondingly — reacting to a link within one second matters far less when the replicas themselves only tick a few times a second anyway.
  • Divisors past 4 for 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 automatically
local 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) })

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

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