Networking Library
Last updated
May be stale
This page was last updated ago, on 2 October 2026. Check what it says against the code before relying on it.
cityrp.network (gamemode/core/libraries/sh_network.lua and core/libraries/network/) is the
unified replacement for three overlapping systems: engine SetNW*/GetNW*, NSVars, and
LocalPlayerVars (core/plugins/networking). See issue #3207
for the full rationale.
Status: migration in progress. cityrp.network is its own tier — above legacy networking
(NSVar/LocalPlayerVar/SetNW*) and below nothing except DTVars — but it is not yet mandatory;
see Networking tiers. The library is live and the first call sites
have moved onto it (gamemode/core/libraries/sh_network_vars.lua), but core/plugins/networking is
still fully live too, and SetNSVar / GetNSVar / SetLPlyVar / SetNW* remain the correct APIs to
use for now — not deprecated, just a lower tier that hasn't been superseded yet. Move a call site as
a deliberate change, with its legacy registration removed in the same commit — not opportunistically
off this topic alone.
Why a new library
- NSVars are player-only. Non-player entities (doors, vehicles, signs) still have no option but
the deprecated
SetNW*/GetNW*tier — see problems/03. - No recipient filtering. NSVars are
net.Broadcast()or nothing; LocalPlayerVars are owner-only or nothing. Nothing in between — no faction-private, gang-private, or police-private state without hand-rolling a newnet.*message. - The registration ID is a positional wire contract. NSVars assign IDs by call order
(
#registered + 1); any load-order divergence between the client and server realm shifts every ID and silently corrupts every value, with no error. - Recycled indices leak state. The client keys NSVar state by raw entity index
(
PLUGIN.NSVars[entIndex]) and never clears an entry on disconnect, so a new player who receives a recycled index inherits whatever the previous occupant left behind. - Broadcast while out of PVS is lost. There is no pull-on-visible path — a value set while a client is out of range never reaches it unless something else happens to resend it later.
Core concepts
A network var is registered once, in shared code, with a builder chain:
cityrp.network.Register("cityrp_VehicleLocked")
:SetEntity()
:Bool()
:SendPVS()
:SetHook("CityRP.VehicleLockChanged")
:Accessor("VehicleLocked")
Registration must happen before the registry seals, which occurs automatically on the
Initialize hook (both realms). Registering afterward is a logged error and the var is never
networked — register vars at file scope or from an early hook, not lazily on first use.
Scope
Exactly one scope per var, set with one of these (default: :SetEntity()):
| Method | Storage | Default recipients |
|---|---|---|
:SetEntity() |
On the entity (ent.__netvars) |
Broadcast |
:SetPlayer() |
Same as Entity — a player is an entity. Routes :Accessor() to FindMetaTable("Player") — see Accessor targets below. |
Broadcast |
:SetVehicle() |
Same as Entity. Routes :Accessor() to FindMetaTable("Vehicle"). |
Broadcast |
:SetWeapon() |
Same as Entity. Routes :Accessor() to FindMetaTable("Weapon"). |
Broadcast |
:SetLocalPlayer() |
On the owning player | That player only, always — recipient methods below don't apply |
:SetGlobal() |
In the library, no owning entity | Broadcast |
:SetEntity(), :SetPlayer(), :SetVehicle(), and :SetWeapon() are identical in storage and wire
framing — cityrp.network.IsEntityScope(scope) is true for all four. The only thing that differs
between them is which metatable :Accessor() attaches its generated methods to. :SetLocalPlayer()
also routes to FindMetaTable("Player"), despite differing in storage/recipients from the other four.
Codecs
Call one codec method per var (default is :Auto() if none is called):
| Method | Wire shape |
|---|---|
:Bool() |
1 byte via net.WriteBool |
:Bit() |
Raw bit via net.WriteBit/net.ReadBit, returns 0/1 rather than a boolean |
:Nibble() / :UNibble() |
4-bit signed/unsigned |
:Byte() / :UByte() |
8-bit signed/unsigned |
:Short() / :UShort() |
16-bit signed/unsigned |
:Int() / :UInt() |
32-bit signed/unsigned |
:Integer(bits) / :UInteger(bits) |
Explicit 1-32 bit width |
:Float() |
32-bit IEEE float |
:Double() |
64-bit IEEE double |
:Long() |
Alias of :Double() — see Integer widths below |
:String() |
net.WriteString — null-terminated, so the writer decides the length |
:String(maxLen) |
Length-prefixed: a fixed-width count, then that many bytes. Variable length, hard ceiling, no padding — see Bounded strings below |
:Vector() |
Three signed components, each auto-choosing float vs. compact integer |
:Normal() |
Compact vector encoding for components that stay within ±1 |
:Angle() |
Same per-component encoding as :Vector() |
:Color() |
RGB plus an optional alpha byte (only sent if alpha isn't 255) |
:Entity() |
net.WriteEntity/net.ReadEntity |
:Player() |
net.WritePlayer/net.ReadPlayer — narrower than :Entity(), sized off MAX_PLAYER_BITS instead of MAX_EDICT_BITS. :Auto() picks this over :Entity() on its own whenever the value is a player. |
:Table() |
Recursive, self-describing — each key/value goes through :Auto() |
:Data(maxLen) |
Fixed-length byte string via net.WriteData/net.ReadData |
:Auto() |
Self-describing: a small type tag, then the value. What NSVars do today. |
:Write(fn, opt) / :Read(fn, opt) |
Custom codec — see Custom codecs below |
:Enum(list) |
Fixed set of legal values — see Enums below |
Integer widths
net.WriteInt/net.WriteUInt only accept 1-32 bits. NSVars' NETTYPE_LONG (64) and
NETTYPE_XLONG (128) passed 64/128 to those functions and could never actually work. Separately,
GLua numbers are IEEE doubles, so no integer above 2^53 can round-trip through Lua regardless of how
it's framed on the wire. :Long() is therefore an alias of :Double() — the widest integer GLua can
actually represent exactly — not a wider integer codec. There is no :XLong(); nothing in Lua can
hold the value, so the name would only reproduce the old illusion.
Bounded strings
:String() reads with net.ReadString(), which runs to a null the sender places — the length is
whatever arrives, capped only by the 64 KB net limit. :String(maxLen) instead writes a length
prefix and reads exactly that many bytes, so the ceiling comes from the registration and never from
the wire.
The prefix is a fixed width (CODEC_ARG_BITS), not one derived from maxLen. Deriving it cost a
day on beta: math.floor(math.log(64, 2)) + 1 answers 6 or 7 depending on whether the expression was
interpreted or JIT-compiled, so the two realms sized the same prefix differently and the payload
arrived shifted by one bit — with the length still decoding correctly, because the extra bit happened
to be zero. Every bit width in this library is now either a constant, written to the wire beside its
value, or carried in the manifest. None is recomputed independently on both realms and assumed to
match.
That distinction is what makes it the only string form :ClientOwned() accepts. :Data(maxLen) is
equally safe to read — its length also comes from the registration — but it is fixed width, not
up-to-width: it pads short values with \0 and costs maxLen bytes regardless, which is right for a
fixed-shape token and wrong for text.
Nothing is ever truncated silently, and the three ways it can happen are not the same severity:
| Where | What it means | What happens |
|---|---|---|
A value longer than maxLen is written |
A bug — data is being lost | Logged as an error naming both lengths, then truncated, exactly as :Integer()/:Data() report being called wrong |
A decoded length prefix exceeds maxLen |
Not truncation — a malformed or hostile message | The read is clamped for safety and the rest of the message is abandoned, the same contract DecodeEntry already has for an unknown var id |
The second row is the one worth understanding: the byte stream is desynced the moment the prefix lies, so clamping and carrying on would corrupt every sibling var batched behind it rather than just the offending one.
maxLen counts bytes, so a truncation can split a multi-byte character. That only happens on a
path that has already logged an error, but it is why the limit should be set from the byte length you
actually mean to allow.
Custom codecs
:Write(fn, opt) / :Read(fn, opt) bypass the built-in codec table entirely. Because the manifest
(below) can't describe an arbitrary function, a custom-codec var is always sent alone in its own
message, never batched alongside other vars — an undecodable custom value can then only ever
corrupt its own message, not a whole batch riding with it. Both realms must register the same var
with matching :Write/:Read calls; a client that never called :Read() for a name the manifest
describes as Custom-coded logs an error and drops that message when one arrives.
Enums
:Enum(list) declares a var's legal values as list, a sequential (ipairs-ordered) array, and
picks between two wire strategies by comparing their actual bit cost:
- Direct value (batchable) — if
listis entirely integers, the value itself goes on the wire, sized to its min/max via:Integer/:UInteger(signed only if the set dips below zero). No offset from the minimum is applied even when every value shares a large common magnitude (e.g.{100, 101}) — an offset would itself need a per-var:Write()/:Read()closure, i.e.CODEC_CUSTOM, which is exactly the cost this path exists to avoid. Order carries no wire meaning here. - Mapped index (not batchable) — the value's index in
listgoes on the wire instead, via an auto-generated:Write()/:Read()pair sized to#list.CODEC_CUSTOMunder the hood (see Custom codecs above), so a resync entry using it never shares a message with sibling vars on the same entity — its ownWriteIndexHeaderplus framing, and its own resync-queue slot. Order does matter here: it's the wire index, so both realms must register the same var with the same list in the same order.
For an all-integer list, the mapped index is only chosen if it beats the direct-value bit cost by
more than cityrp.network.RESYNC_GROUP_OVERHEAD_BITS (24), the approximate resync-framing cost of
escaping batching. A list containing anything non-integer (strings, mixed types) always uses the
mapped index; there's no numeric value to size a direct encoding from.
Either way, Setting a value that isn't in list is rejected — logged and dropped, never stored or
sent — rather than silently encoding as something adjacent or vanishing into no enum value at all.
nil (clearing the var) always bypasses this check.
-- Direct value: dense, low-magnitude set — 3 bits, batchable.
cityrp.network.Register("cityrp_Rank"):SetPlayer():Enum({1, 2, 3, 4}):Accessor("Rank")
-- Mapped index: small set, but string-valued (no direct encoding available) — 3 bits, not batchable.
cityrp.network.Register("pass"):Enum({"grey", "green", "blue", "orange", "red"}):Accessor("Pass")
Recipients
Only meaningful for Entity/Player/Vehicle/Weapon/Global scope. LocalPlayer scope always sends to its
one owner and its recipient mode is ignored on every path, so a :Filter() on a LocalPlayer var is
inert rather than redundant — the registry warns about that pairing when it seals.
| Method | Behaviour |
|---|---|
:Broadcast() |
Default. Sends to everyone. |
:SendPVS() |
net.SendPVS(ent:GetPos()) — everyone who can potentially see the position. |
:SendPAS() |
net.SendPAS(ent:GetPos()) — everyone who can potentially hear it. |
:SendOmit(fn) |
fn(ent, value) returns who to exclude; everyone else gets it. |
:Filter(fn) |
fn(ent, value) returns who receives it — a Player, a Player[], or a CRecipientFilter. |
:Filter() is the only secrecy boundary. PVS, PAS, and Omit are treated as bandwidth
optimizations, not authorization: resync and the join bootstrap (below) always include a PVS/PAS/Omit
var's current value for a player who's asking, because pull-on-visible only fires once the client is
already in a position to receive it anyway. If a value must never reach a player at all — faction,
gang, or police-private data — use :Filter(), not :SendOmit(). :Filter()'s callback doubles as
that authorization check: the same function that decides who gets a live update also decides who's
allowed to pull it via resync, so there's exactly one predicate to keep correct, not two that can
drift apart.
That guarantee is evaluated at send time, not continuously. A filter runs on exactly three occasions, and nothing else re-runs it:
- The value is set (
Entity:SetNetVar/cityrp.network.SetGlobal). - The entity re-enters a client's PVS, and that client pulls a resync.
- Someone calls
cityrp.network.Invalidate— see below.
Eligibility, though, usually depends on player state — access flags, vehicle keys, door access, team, rank — which changes independently of both the value and the entity's visibility. The library cannot observe that, so whatever changed it has to say so. Without (3), a var written once and left alone is only re-evaluated by the accident of map geometry that takes its entity out of a player's PVS and back, and a player who loses eligibility keeps whatever their client was last sent.
Keep :Filter() callbacks cheap and free of side effects — they run once per set, again per resync
candidate, and again per player per invalidation. In particular a callback must not set a var or call
Invalidate: both mutate the resync queue that may be mid-iteration when the callback runs.
Invalidating a filter
cityrp.network.Invalidate(ply, ent, key) re-evaluates a filter when neither the value nor the
entity's visibility changed. A player who has become eligible receives the current value; a player
who has stopped being eligible has it replaced — by the var's :SetDefault() if it has one, or by
nil if it doesn't — which fires their :SetHook(). Server-only.
| Shape | Example |
|---|---|
| one player, one entity, one key | this player's key access to this vehicle changed |
| one player, all entities, one key | this player's staff state changed; re-evaluate the PRN everywhere |
| all players, one entity, one key | this vehicle was sold; everyone re-evaluates its owner |
-- Called from whatever changed the eligibility, not from the networking layer.
cityrp.network.Invalidate(ply, vehicle, "cityrp_VehicleOwner")
cityrp.network.Invalidate(ply, nil, "cityrp_PRN") -- every entity carrying it
cityrp.network.Invalidate(nil, vehicle, "cityrp_VehicleOwner") -- every player
ply and ent each take a single value, an array, or nil for "all of them". ply does not
accept a CRecipientFilter — pass filter:GetPlayers(). Players who haven't finished their handshake
are skipped: their join bootstrap is still to come and evaluates every filter fresh when it drains.
key is required. A nil key would mean "re-run every filter on every var", which is the blunt
behaviour this exists to replace. It is also what makes the call cheap: a var name uniquely identifies
its scope, so the key alone decides whether the call routes to the entity resync queue or straight to
the Global/LocalPlayer path, and only that key is queued rather than the entity's whole var set. An
array mixing the two scopes is split and routed both ways.
ent = nil scans every entity, skipping any that has never had one of the named vars set. That is the
expensive shape — it is fine for the discrete, rare events invalidation is for, and wrong for anything
per-frame.
Masking a value instead of clearing it
A :SetDefault() on a :Filter()ed var is what a player who fails the check is told instead.
Pair the two and the var gates itself: eligible players get the truth, everyone else is sent the
default, and the real value never reaches them at all.
-- Everyone sees "user"; only staff see the real rank.
cityrp.network.Register("_svrank"):SetPlayer():String():SetDefault("user")
:Filter(function(ply) return <viewers allowed to see the truth> end)
This is the shape to reach for when the value must look ordinary rather than missing — a staff rank
that should read as "user" while undercover, rather than as "no rank". Without a default the var is
cleared outright, which is right for something whose absence is unremarkable.
Sending the default rather than a bare nil is what makes the two agree: Entity:GetNetVar already
falls back to the default whenever a value is absent, so a filtered-out client would read "user"
either way — but only this way does its :SetHook() fire with "user" rather than with nil,
matching what every reader will now get.
Two things worth being clear about:
- The mask is fixed, not per-player. Every filtered-out player is told the same thing. Showing different players different fabricated values needs a genuine second value on the wire, which this is not.
- The default is validated where it lands. A
:SetDefault()outside its var's:Enum()would be rejected on every client on every revoke, so the registry warns about that pairing when it seals.
Known limitations.
- A var cleared server-side while a player is filtered out is unrecoverable. Clearing removes the
key from
ent.__netvarsentirely, so it drops out of iteration and no later resync can mention it; that player keeps the old value until the entity is removed. If it ever needs closing, the cheap fix is a per-entity set of names that have ever been set — one small table per entity, not per player-entity pair. :SetNoSync()+:Filter()has no revocation path. AnoSyncvar is excluded from resync entirely, which is the only path a revoke travels. Arguably correct for a push-only var, but stated here rather than discovered.:Filter()on:SetLocalPlayer()is inert, soInvalidateon a LocalPlayer key can only re-deliver, never revoke. That scope is owner-only by construction.- A set that itself narrows eligibility needs its own
Invalidate. The callback receives(ent, value), so the value can decide who is eligible — and a live set only ever goes to the new value's recipients. A player who passed the old value's filter and fails the new one keeps the old value and is never told.
The last three are what the registry's seal-time warnings and this section exist to make visible; none of them fails loudly on its own.
Behaviour
| Method | Effect |
|---|---|
:SetHook(name) |
Runs hook.Run(name, ent, newValue, oldValue, predicted) on both realms when the value changes. predicted is true only for a client-side local apply the server hasn't confirmed — see below. |
:AutoHook() |
Shorthand for :SetHook("CityRP.Network.<name>.Changed") — use when you don't care what the hook is called. |
:Accessor(name) |
Generates Get<Name>() (both realms) and Set<Name>() (server only, unless :Predictable()) — see Accessor targets below for which metatable. |
:Predictable() |
Also generates a client-side Set<Name>() for local prediction — see below. Only valid on :SetLocalPlayer(). |
:ClientOwned() |
Lets the client set this var on its own player and have the server accept it — see Client-owned vars below. Requires :SetPlayer()/:SetLocalPlayer(), a bounded codec, and a :Debounce(). |
:Debounce(seconds[, ceiling]) |
Coalesces sets to at most one change per seconds — see Debouncing below. ceiling reserves the shape for adaptive backoff; omit it and the interval never moves. |
:SetNoSync() |
Excludes the var from the join bootstrap and resync — push-only. A client that was out of PVS during a push never gets it, and a :Filter() on it has no revocation path — see Invalidating a filter. |
:SetDefault(value) |
What Entity:GetNetVar/cityrp.network.GetGlobal return before anything is set — and, on a :Filter()ed var, what a player who fails the check is sent instead of the real value. See Masking a value instead of clearing it. |
Client-side prediction
:Set<Name>() is normally server-only — setting is server-authoritative, and the underlying
SetNetVar/SetGlobal don't exist on the client realm at all (sv_send.lua is a server-only file).
:Predictable() opts a :SetLocalPlayer() var out of that: the client gets its own Set<Name>()
too, which applies the value locally (storage + the :SetHook() hook, with predicted = true — never
a network send, the client has no authority to send anything) instead of calling into the
server-only machinery. :ClientOwned() below is the other way out of that rule, and the one that
does send.
This is for the standard prediction-and-reconciliation shape: client-side code that runs under
IsFirstTimePredicted() calls Set<Name>() immediately for instant feedback, while the server keeps
computing and pushing the authoritative value exactly as it always did. When that confirmed value
arrives back at the client through the normal decode path, it overwrites whatever was predicted
locally — correcting any drift the same way it always corrected a live update, no special-case
reconciliation code needed. If the client's prediction already matched, the delta guard in
ApplyValue means nothing observable happens; if it didn't, the value snaps to the server's.
Only valid for :SetLocalPlayer() — a client predicting some other entity's state has no
server-authoritative copy of that entity's future value to reconcile against, so the scope doesn't
make sense there. Using :Predictable() on any other scope logs an error at accessor-generation time
and simply doesn't generate the client-side setter, rather than producing a footgun that looks like
it works.
cityrp.network.Register("_Stamina"):SetLocalPlayer():UByte():Accessor("Stamina"):Predictable()
-- In shared code, guarded the same way any other predicted mutation is:
if SERVER or IsFirstTimePredicted() then
ply:SetStamina(newValue) -- works on both realms now
end
Client-owned vars
:ClientOwned() is the only supported client→server write path. The generated Set<Name>() becomes
available on the client, where it applies the value locally and then sends it; the server validates
the sender, decodes it, and hands it to ply:SetNetVar() — the ordinary server-side setter.
cityrp.network.Register("cityrp_F1Open"):SetPlayer():Bool():Accessor("F1Open")
:ClientOwned():Debounce(1):SetDefault(false)
-- Client, wherever the menu opens and closes:
LocalPlayer():SetF1Open(true)
Because the receiver hands off to SetNetVar, everything else composes for free: the var's
:Enum() validator, its delta guard, its :Debounce() and its recipient mode all behave exactly as
they would for a server-set value. A client-owned var is not implicitly a broadcast one. :Filter()
is the interesting combination, since its callback doubles as the resync authorization check — "only
my faction sees this claim" is a supported shape — and :SetLocalPlayer() is coherent too: the claim
reaches nobody but the sender, with a server-side :SetHook() doing the work.
The value is a claim, not a fact. The library relays it faithfully and never second-guesses it,
so it carries exactly the authority the sender has: none. Never gate permissions, economy or
anti-cheat on one. The worst a liar can do to cityrp_F1Open is produce fake overhead text — that is
the bar a new client-owned var has to clear.
Constraints, all checked when the registry seals, each a logged error that clears the flag rather than a silently-generated hole:
:SetPlayer()or:SetLocalPlayer()scope. The message carries no entity index — the server applies it to whoever sent it — so a client cannot address another entity. That is structural, not a check to remember.- A bounded codec. The test is not "is it small" but "who decides how much the server reads".
Fixed-width codecs,
:Enum(),:Data(maxLen)and:String(maxLen)all read a width fixed by the registration.:Auto()(a wire-supplied type tag), unbounded:String()and:Table()all let the sender decide, and are refused. A hand-written:Write()/:Read()pair is refused for the same reason;:Enum()'s generated one is admitted, because its read is a fixed-width index. - A
:Debounce(). Accepting unthrottled client-authored state isn't supported.
Chaining :Predictable() as well is redundant — client-owned already applies locally first — and
warns rather than erroring.
Sets made before the manifest arrives are buffered and flushed once it lands, latest-wins per
var. Accessors exist from Initialize, but the client's var IDs come from the manifest, which
arrives after InitPostEntity; without the buffer, a var pushed once at startup ("my setting is X")
would be silently lost. The local apply still happens immediately — only the send waits.
Claims are not adjudicated
Whether a claim is currently meaningful — the player is alive, awake, not spectating, in range, on the right job — is decided by whatever consumes it, never at the point of claim. There is deliberately no server-side accept predicate and no server-side clear.
A rejecting predicate would strand state. Player opens F1 while alive, is killed, closes F1 — the predicate refuses the close because they're dead, and "In Inventory" sits over their head for the rest of the round. Every fix for that is machinery that exists only to undo damage the predicate caused.
The condition also belongs at the consumer anyway, where it is shared: the overhead renderer
already needs a liveness test, and one test there covers cityrp_Typing, cityrp_F1Open and every
claim added later, rather than each var repeating the same three checks.
The check does not disappear, though — it moves, and is now the consumer's to write.
Debouncing
:Debounce(seconds) caps how often a var may actually change. It is not client-only — any
high-churn var can opt into it — but :ClientOwned() requires one.
- A set during a quiet period applies and dispatches immediately. No artificial latency on the common case.
- A set inside the window overwrites a single pending slot, latest wins, no accumulation.
- The pending value is applied when the window closes.
Coalescing rather than a token bucket, because these are state, not events. A bucket drops updates, and a dropped "F1 closed" leaves everyone seeing stale overhead text until that player opens and closes F1 again at a politer rate. Coalescing can't do that — the last value always lands, it just lands late.
Most of the saving comes from the delta guard rather than the throttling: a rapid
true → false → true → false burst settles on a value that usually equals what is already stored, so
the flush reports no change and nothing goes on the wire at all. Pathological spam costs one
inbound decode per message and zero outbound messages.
The debounce covers the apply, not only the send, so there stays exactly one notion of a var's
current value. The cost, and the one surprising consequence: a server-side GetNetVar lags a
SetNetVar by up to the interval, so the two no longer round-trip within a frame.
ceiling reserves the shape for adaptive backoff — escalate on sustained dispatches, reset to the
floor after a full quiet window. It is inert today: omitting it sets ceiling == seconds, so the
interval never moves. Escalation counts dispatches rather than inbound messages deliberately, since a
burst that coalesces back to its original value costs the server nothing and must not be throttled
for it.
Pending and escalated pairs are drained on Think against a weak-keyed set, the same shape the
resync queues use, rather than a timer per player per var.
Accessor targets
:Accessor(name) attaches to FindMetaTable("Entity") for Entity/Global scope, FindMetaTable("Player")
for Player/LocalPlayer scope, and FindMetaTable("Vehicle")/FindMetaTable("Weapon") for those.
Calling Entity:GetNetVar/SetNetVar itself would already work identically regardless — GMod
resolves a method not found on a more specific metatable by falling through to Entity's — but the one
case that fallthrough doesn't cover is a same-named method the more specific metatable already
defines natively: it would shadow an Entity-attached Get<Name>/Set<Name> for every entity of that
type, silently calling the native method instead of yours. Attaching directly to the matching
metatable instead puts your accessor closer than Entity's in the lookup chain, so it wins.
This is a real, present-day risk, not a hypothetical one: sv_vehicle.lua's locked, engine_on,
and HP state — the values modern-apis.md names as the reason vehicles still use
the otherwise-avoided SetNW* tier — are exactly the kind of vehicle-specific accessor a scope 2
migration would want to name Locked/EngineOn/HP, and FindMetaTable("Vehicle") is already
extended in this codebase (core/metatables/sh_vehicle.lua).
Reading and writing
-- Server only
ent:SetNetVar("cityrp_VehicleLocked", true)
cityrp.network.SetGlobal("WeatherState", 2)
-- Both realms
local locked = ent:GetNetVar("cityrp_VehicleLocked", false)
local weather = cityrp.network.GetGlobal("WeatherState", 0)
-- Server only; runs fn(ply) once ply's handshake completes, or immediately if it already has
cityrp.network.WaitForPlayer(ply, function(ply)
ply:SetNetVar("cityrp_Job", "Citizen")
end)
-- Client only, and only for a :ClientOwned() var. Equivalent to its generated Set<Name>(); use it
-- directly for a var registered without :Accessor().
cityrp.network.ClientSet("cityrp_F1Open", true)
Entity:SetNetVar/SetGlobal on an unregistered name, the wrong scope's accessor (e.g.
Entity:SetNetVar for a Global-scoped var), or :SetNetVar for a LocalPlayer-scoped var on a
non-player entity are all logged errors, not silent no-ops or thrown exceptions.
Wire protocol
Four net strings, two used in both directions:
| Message | cl → sv | sv → cl |
|---|---|---|
cityrp.network.Sync |
Client's net channel is ready | The manifest, then the join bootstrap |
cityrp.network.Resync |
Batched entity indices to resync | Batched per-entity/global var groups |
cityrp.network.Update |
— | A single live var change |
cityrp.network.ClientSet |
One :ClientOwned() var, for the sending player |
— |
ClientSet carries a bare var entry and no entity index: the server applies it to whoever sent
it, so there is nowhere in the message to name another entity. One var per message, never batched, so
a desynced entry can only ever cost that one var. The entry's wire shape is written and read by the
same WriteVarEntry/DecodeEntry pair the server's own paths use — both live in sh_store.lua
rather than one per realm, because a matched pair split across two files is exactly what AGENTS.md
footgun #11 is about.
The manifest is what fixes the positional-wire-contract bug. The server seals the registry
(sorted var names → sequential IDs) and sends every client id → name, scope, codec, arg, with the
ID bit-width itself in the header. The client never assigns its own IDs — it waits for this message
and matches entries by name. A load-order difference between realms can no longer desync anything: at
worst, a var one realm doesn't have registered locally gets a synthetic entry built straight from the
manifest, logged once, and keeps working via GetNetVar/SetNetVar.
A joining player's Sync announce is retried every 3 seconds (up to 20 attempts) until the manifest
arrives — a wide margin over the multi-second stalls a client can hit while loading assets right after
joining. The server does not send Update/Resync traffic to a player until their handshake
completes — an update sent before the manifest would be undecodable, and because sets are
delta-guarded, that value would then be lost for good.
Storage and the recycled-index bug
Entity/Player-scoped values live directly on the entity's own Lua table (ent.__netvars), the same
way Photon SimpleNet stores state, on both realms. This retires the stored half of NSVars'
recycled-index bug: GMod hands a freshly-created entity a new Lua table regardless of which index it
reuses, so nothing stored on the last occupant carries over to the next.
The half still in flight is ordering. The client receives entity creation and removal in snapshots,
and a snapshot can overtake a net message the server sent before it. A kicked player stays valid on
the server until they finish disconnecting, so an Update or Resync for them can reach the client
after their slot is empty, where it would be buffered, or after the next player already holds it.
Either way the old occupant's values would land on the new one, and since the server holds nothing
for the new player there, nothing would ever correct them.
A player's index header therefore carries their UserID as well, modulo 2^16 (USERID_BITS). On the
client:
- A value for a player other than the one in the slot is dropped if that player came earlier, since
UserIDonly climbs, and buffered if they came later, for when the client creates them. - A slot's buffer belongs to one player: a value for a later player replaces it, a value for an earlier one is dropped, and it is discarded if the player created in the slot is not its owner.
Non-player entities carry no identity on the wire, so the race remains open for them.
Resync
Modeled on Photon SimpleNet's pull-on-visible design:
-
Client requests a resync when an entity is created over the network (
GM:NetworkEntityCreated) or re-enters this client's PVS (GM:NotifyShouldTransmit). Requests debounce to at most one batched message per frame. -
Server queues the requested entities per player and drains the queues from
Think, subject to two ConVars:ConVar Default Meaning cityrp_network_resync_rate32 Entities resynced per frame, server-wide. cityrp_network_resync_rate_player8 Max entities resynced per frame for any one player. Draining is round-robin across players with a persistent cursor, so a player with a large queue cannot starve everyone else — every player with a non-empty queue gets served within a bounded number of frames regardless of how full anyone else's queue is.
-
Each player's queue is a weak-keyed table of entities (
__mode = "k"), not an ordered array. Both a disconnecting player's whole queue and an individual removed entity clean themselves out of every queue automatically;PlayerDisconnected/EntityRemovedhooks additionally purge eagerly rather than waiting on the next GC pass.Each entry says which of that entity's vars to resync, and whether a
:Filter()ed var the player has stopped passing is explicitly cleared from their client:Entry Queued by Covers Revokes truecityrp.network.QueueEntity(ply, ent)Every var the entity has set yes a set of var names cityrp.network.QueueEntityVars(ply, ent, names)Only those vars yes falseEnqueueAllEntitiesForResync(ply, false)Every var the entity has set no "Only these names, and no revokes" is deliberately unrepresentable — the only caller that suppresses revokes is the join bootstrap, and it always wants every var.
Most resync triggers are single-var events, and re-running every var's recipient check when only one var's eligibility could have changed is the dominant cost of a resync — so a caller that knows precisely what changed can say so.
cityrp.network.MergeQueueEntrycombines an incoming entry with whatever is already queued, and always keeps the broader of the two on both axes:trueabsorbs everything, two name sets union, andfalsemerged with a name set becomestrue— each is broader than the other on one axis, andtrueis the only shape that satisfies both. That makes merging monotonic, so no ordering of calls within a frame can produce a narrower entry than the broadest request made.A name set is stored by reference, so one caller narrowing the same key across many player/entity pairs shares a single table rather than allocating one per pair. A stored set is therefore immutable —
MergeQueueEntrybuilds a new table rather than widening one in place. -
The join bootstrap reuses this same path: on handshake, every currently-known networked entity is queued for the new player exactly as if it had just re-entered their PVS, so a join gets the same per-frame rate limiting as any other resync rather than a separate bulk-send path to reason about. Global values and the joining player's own LocalPlayer-scoped values — neither tied to any entity's visibility — are sent once, directly, alongside the manifest.
The bootstrap is the only thing that suppresses revokes. A client holding a manifest it received seconds ago has no stale value to replace, and emitting one entry per filtered var per entity would put a group header on the wire for every entity that produces no message at all today. A PVS-driven resync does emit them — that client may well have gone out of PVS eligible and come back ineligible.
The wire format is unchanged by any of this: a narrowed group is just a group with fewer vars in it,
and a revoke is an ordinary var entry — a var id plus a single isNil bit for a var with no default,
or the encoded default for one that has it (see Masking a value instead of clearing it).
cityrp_network_sync_bytes (default 8192) is the flush threshold, in bytes, for a batched Resync
message — checked between entity groups, so a message can exceed it by up to one group's worth of
vars. That default leaves roughly 57 KB of headroom under the 65536-byte net message cap.
Diagnostics
cityrp_network_dump — server console/RCON only (same guard as cityrp_log_set). Prints the sealed
registry (id, name, scope, codec, whether the var is :Filter()ed, plus clientOwned and
debounce=floor..ceiling where they apply), per connected player their handshake status and resync
queue depth, and every (owner, var) pair currently mid-window or escalated off its debounce floor.
That last block matters because an escalated interval is otherwise invisible — it presents only as
"this player's indicator is laggy".
queued= counts entities; a breakdown of how many of those entries are narrowed to specific vars, and
how many are bootstrap entries, is appended only when there are any.
cityrp_network_clientset_rate (default 30) is the inbound backstop: ClientSet messages one player
may send per second before the rest of that second is ignored and a warning logged. It is a floodgate,
not the throttle that makes client-owned vars viable — :Debounce() is that, and it already flattens
every legitimate burst — which is why it can sit well above any real usage.
cityrp_network_selftest — same guard. Re-runs the library's server-local assertions (the resync
queue's merge lattice, and that a failed :Filter() check emits a revoke only when revokes are
enabled) and logs a pass/fail summary. Safe to run at any time on any server; the assertions are pure
and read-only.
Verbose per-message logging goes through the existing logging library, not a dedicated ConVar:
cityrp_log_set network DEBUG
What this library does not do
- Most call sites have not been migrated.
SetNSVar/GetNSVar/SetLPlyVar/SetNW*remain correct for everything still on them — see issue #3207 for the full plan (compatibility shims fordamagelog,vape_swep,mapaddonsand the Discord relay's fakeMT:GetNSVarstub, then deletion ofcore/plugins/networking).core/plugins/networkingis still fully live. - No client authority over anything but the sending player.
:ClientOwned()is:SetPlayer()/:SetLocalPlayer()only; there is no client-driven Global or Entity scope, and no way for a client to write another entity's state. - No server-side adjudication of a claim. No accept predicate, no server-side clear — see Claims are not adjudicated above for why both are deliberate omissions rather than gaps.