Networking¶
[KEY] — what a client may tell the host, and who is trusted. The decision is ADR-0005: the host is authoritative over all gameplay state. This document is the concrete convention set every networked feature follows.
1. Engine primitives in use¶
| Primitive | Purpose | Reference |
|---|---|---|
GameObject.NetworkSpawn() |
Makes a GameObject networked |
API |
[Sync] |
Replicates a property; owner may write | Sync Properties |
[Sync(SyncFlags.FromHost)] |
Replicates a property; only the host may write | same |
[Sync(SyncFlags.Interpolate)] |
Smooths changes client-side between updates | same |
[Sync(SyncFlags.Query)] |
Checked for changes every network update instead of on assignment | same |
[Rpc.Broadcast] |
Callable by anyone; runs on everyone | Networking & Multiplayer |
[Rpc.Host] |
Callable by anyone; runs on the host only | same |
[Rpc.Owner] |
Callable by the host; runs on the object's owner only | same |
Rpc.Caller |
Inside a [Rpc.Host] method, identifies who called it |
same |
Component.IsProxy |
True when this component instance is a replicated copy, not the authoritative one | Networked Objects |
Rpc.FilterInclude(...) / Rpc.FilterExclude(...) |
Scopes the next [Rpc.Broadcast] call to a subset of connections |
§5 below |
2. The four rules¶
Restated from ADR-0005 because they are the whole document in miniature:
- Host-owned by default.
[Sync(SyncFlags.FromHost)]unless there is a specific, commented reason for plain[Sync]. - Requests are typed
[Rpc.Host]calls, re-validated host-side. Client checks are advisory UI only. - Replicate state on change; send moments as RPCs. Nothing is polled.
- Private state replicates only to its owner.
3. Sync conventions¶
public sealed class CharacterEconomy : Component
{
/// <summary>
/// The character's cash on hand. Host-owned: a client that could write this could
/// print money. See ADR-0005.
/// </summary>
[Sync(SyncFlags.FromHost)] public int Money { get; private set; }
/// <summary>
/// The player's look direction for third-person animation. Owner-writable: it is the
/// client's own input intent, and forging it only misrepresents the forger's own
/// character cosmetically -- it cannot be profited from. Justifies plain [Sync] per
/// ADR-0005 rule 1.
/// </summary>
[Sync] public Angles LookAngles { get; set; }
}
Every plain [Sync] (non-FromHost) property carries the justifying comment inline, not in
a separate document — the review checklist item in
ADR-0005 treats a missing comment
as a rejection, and a comment that is easy to find at the property is a comment that is easy
to keep honest.
Replication scope. A character's Money, inventory, and needs are [Sync] state on a
component that only its owning connection receives — private by construction, per rule 4.
Public state (a door's lock status, a team's score) is [Sync] with no scope restriction.
The mechanism for owner-only replication is the object's network ownership, set once at
spawn and not reassigned casually; see
Networked Objects for the
ownership/ "who receives updates" relationship.
4. RPC conventions¶
public sealed class PropertyService : Component
{
/// <summary>
/// Requests purchase of <paramref name="doorId"/> for the calling connection's
/// character. Client-side, only greys out a button; the host performs the entire
/// validation-and-mutation atomically. See ADR-0005 rule 2.
/// </summary>
[Rpc.Host]
public void RequestPurchaseDoor(Guid doorId)
{
var character = Rpc.Caller.GetCharacter();
if (character is null || !character.IsInitialised) return;
var door = _property.Find(doorId);
if (door is null || door.IsOwned) return;
if (character.DistanceTo(door) > InteractionRange) return;
if (!_economy.CanAfford(character, _config.DoorCost)) return;
_property.Purchase(character, door); // atomic: money and ownership, or neither
}
}
Every parameter is typed — an int, a Guid, an enum, never a raw string parsed
downstream. Typed parameters remove the entire parsing-bug class the legacy
concommand.Add("cider", …) dispatcher had, for free
(D-02).
The host re-validates everything, restated as a checklist because it is the single most security-relevant paragraph in this codebase:
- Identity —
Rpc.Callerresolves to a real, initialised character. - Existence — the target still exists and is the type expected.
- Ownership / authority — the caller is allowed to act on this target.
- Distance — the caller is physically close enough, if that is a real-world constraint.
- State — the target and caller are in a state where the action is legal (not arrested, not already owned, not on cooldown).
- Affordability — where money is involved, checked host-side even though the client's UI already checked it.
This is the same list as the exploit
checklist every [Rpc.Host]
method is tested against before merge.
Rate limits are declared, not hand-rolled. A shared rate-limiting facility
([Rpc.Host, RateLimit(...)] or equivalent, detailed alongside its first real usage) replaces
the legacy's ad-hoc per-command _Next* timestamp fields.
5. Filtered delivery: targeting a subset of connections¶
[Rpc.Broadcast] alone only offers two shapes: everyone, or (via [Rpc.Host]/[Rpc.Owner])
one fixed target. Neither expresses "everyone satisfying a predicate computed at send time" —
e.g. "everyone with chat.admin", "this channel's already-computed recipient list", or "just
the one connection that called this RPC". Rpc.FilterInclude/Rpc.FilterExclude close that
gap: wrapped around a [Rpc.Broadcast] call site, they scope delivery to (or away from) a
supplied Connection, IEnumerable<Connection>, or Predicate<Connection>, enforced at the
actual packet-send point (NetworkSystem.Send, confirmed by reading the engine's own send
loop) — an excluded connection never receives bytes at all. This is not a client-side hide; it
is a real server-side subset send, the same guarantee [Sync]'s owner-only scope gives a
single connection, generalized to an arbitrary set.
using ( Rpc.FilterInclude( recipientConnections ) )
{
ReceiveChatLine(senderName, channel, text); // [Rpc.Broadcast]; only recipientConnections get it
}
Two distinct shapes appear in this codebase, and they should not be confused as needing the same overload:
- Variable-size recipient list (chat's per-channel recipients, computed fresh per send) —
Rpc.FilterInclude(IEnumerable<Connection>). SeeChatNetworkComponent(Code/Chat/ChatNetworkComponent.cs), driven byChatMessageSent.Recipients. - Single target (a reply that only the original caller should see) —
Rpc.FilterInclude(Connection), the dedicated single-connection overload, not a one-element list. SeeCommandDispatchComponent.ReceiveCommandReply(Code/Commands/CommandDispatchComponent.cs).
Both cases need to resolve a Character to its owning Connection — the inverse of the
Connection.GetCharacter() lookup already documented in §4's checklist material
(Code/Characters/ConnectionExtensions.cs). The inverse, Character.FindConnection(), lives
in the same file and matches its SteamId-keyed lookup, comparing .ValueUnsigned (ulong) on
both sides rather than the struct's implicit numeric conversions, per the convention already in
use in Code/Admin/AdminKickCommand.cs:
public static Connection? FindConnection(this Character character) =>
Connection.All.FirstOrDefault(c => c.SteamId.ValueUnsigned == character.OwnerAccountId.ValueUnsigned);
Returns null if the character's connection has since disconnected — callers must treat that
as a silent skip (the recipient just doesn't get the message), never a crash or a retry.
6. What is a [Sync] property vs. an RPC¶
| Kind | Mechanism | Why |
|---|---|---|
| Durable state (money, hunger, lock status, owner, arrest status) | [Sync] property |
A late-joining or reconnecting client converges to the current value automatically |
| A moment in time (a chat line, a notification, a gunshot sound) | RPC | There is no "current value" to converge to — it either happened or it didn't |
| Bulk, rarely-changing data (help text, the ten laws, a container's full contents on open) | Sent once, on demand, via RPC | Would be wasteful to keep [Sync]-replicated to everyone continuously |
Nothing is polled. A timer that re-sends unchanged data on an interval is a defect, not an
optimisation — the direct fix for D-11, where thirteen
variables were re-sent to every player every second regardless of change. [Sync] without
SyncFlags.Query already only sends on assignment; there is no additional timer to write.
7. A traced flow: buying a door¶
The same operation Legacy/13_DEPENDENCY_GRAPH.md §3 traces through six legacy systems and three transports. Here:
sequenceDiagram
participant C as Client
participant H as Host: PropertyService
participant E as Economy service
participant P as Property service
participant B as Event bus
C->>H: [Rpc.Host] RequestPurchaseDoor(doorId)
H->>H: validate identity, existence, ownership, distance, state, affordability
H->>E: CanAfford / DebitAsync (atomic with ownership change)
H->>P: AssignOwner(door, character)
H->>B: Publish(DoorPurchased)
Note over H: Door.IsOwned and Door.OwnerName are [Sync] -- replicate automatically
Note over H: Character.Money is [Sync(FromHost)] on the buyer's own object -- replicates to them only
One RPC, one atomic host-side operation, two [Sync] properties that converge on their own,
one event for anything else that cares (logging, a scoreboard, a tax calculation). No manual
network message construction anywhere in this flow.
8. Both host mode and dedicated servers¶
The FromHost model applies unchanged to both S&box connection topologies — in host mode,
the host player is the trust boundary, which is the same trust assumption as "a dedicated
server, plus trusting whoever is hosting", stated plainly rather than hidden
(ADR-0005). Host migration is out of
scope: if the host disconnects in host mode, the session ends.
9. Budget and measurement¶
Per-milestone, per 00_CONSTITUTION.md's Definition of Done: measure bytes/second per player at idle. It should be near zero — a non-zero idle cost means something is polling and is a defect, not a tuning question. There is no fixed byte budget under load, because activity-proportional cost is the entire point of rule 3; the metric that matters is the shape of the graph (flat at idle, proportional to actions) rather than an absolute number.
10. Testing¶
- Validation rules (distance, affordability, state checks) live in engine-agnostic types (Standards/00_CODING_STANDARDS.md §8) and are unit-tested independently of the RPC that calls them.
- Every
[Rpc.Host]method is run against the exploit checklist before merge. - Multiplayer testing with at least two connected clients is required before merge for any networked feature (Standards/03_TESTING_STANDARDS.md §5).