CityRP Documentation Repository

Networking Library

Last updated

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 new net.* 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 list is 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 list goes on the wire instead, via an auto-generated :Write()/:Read() pair sized to #list. CODEC_CUSTOM under the hood (see Custom codecs above), so a resync entry using it never shares a message with sibling vars on the same entity — its own WriteIndexHeader plus 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:

  1. The value is set (Entity:SetNetVar / cityrp.network.SetGlobal).
  2. The entity re-enters a client's PVS, and that client pulls a resync.
  3. 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.__netvars entirely, 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. A noSync var 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, so Invalidate on 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 UserID only 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:

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

  2. Server queues the requested entities per player and drains the queues from Think, subject to two ConVars:

    ConVar Default Meaning
    cityrp_network_resync_rate 32 Entities resynced per frame, server-wide.
    cityrp_network_resync_rate_player 8 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.

  3. 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/EntityRemoved hooks 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
    true cityrp.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
    false EnqueueAllEntitiesForResync(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.MergeQueueEntry combines an incoming entry with whatever is already queued, and always keeps the broader of the two on both axes: true absorbs everything, two name sets union, and false merged with a name set becomes true — each is broader than the other on one axis, and true is 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 — MergeQueueEntry builds a new table rather than widening one in place.

  4. 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 for damagelog, vape_swep, mapaddons and the Discord relay's fake MT:GetNSVar stub, then deletion of core/plugins/networking). core/plugins/networking is 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.