Skip to content

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:

  1. Host-owned by default. [Sync(SyncFlags.FromHost)] unless there is a specific, commented reason for plain [Sync].
  2. Requests are typed [Rpc.Host] calls, re-validated host-side. Client checks are advisory UI only.
  3. Replicate state on change; send moments as RPCs. Nothing is polled.
  4. 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.Caller resolves 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>). See ChatNetworkComponent (Code/Chat/ChatNetworkComponent.cs), driven by ChatMessageSent.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. See CommandDispatchComponent.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