Skip to content

ADR-0005 — Host-authoritative networking

Status: ✅ Accepted Date: 2026-07-27 Supersedes:Superseded by:


Context

The original's networking is unsalvageable and instructive. Four transports (usermessage, datastream, NWVar/DTInt, and concommand for everything client→server), none of which survives in a modern engine (Legacy/11).

More importantly, its authority model has two failures that are independent of the transport and would recur in any naive port:

Nothing validates client input. Every client action arrives at a single concommand.Add("cider", …) dispatcher as an array of raw strings. It checks argument count and one access flag, then pcalls the handler. No type validation, no range validation, no distance check, no ownership check beyond what each handler happens to implement (D-02).

State is polled, not replicated. SetCSVar's dirty check compares the CSVars table itself against the incoming value, so it is always true and the check never fires; thirteen variables are then re-sent to every player every second regardless of change. A server full of idle players pays exactly as much as one in a firefight (D-11). Meanwhile inventory is delta-only with no reconciliation, so one dropped message desyncs a client until relog (D-05).

The engine offers a direct replacement. A GameObject becomes networked via NetworkSpawn(), after which [Sync] properties replicate on change and RPCs can be invoked remotely. [Sync] alone permits the object's owner to write; [Sync(SyncFlags.FromHost)] restricts writes to the host. RPCs are directed with [Rpc.Broadcast], [Rpc.Host] and [Rpc.Owner]. Both host-mode P2P and dedicated servers are supported.

The relevant point: [Sync] — the shorter, more obvious attribute — is the permissive one. The safe choice is the longer one, so the default must be stated explicitly or it will drift.

Decision

The host is authoritative over all gameplay state. Clients render and request; they never decide. Every client→server request is a typed [Rpc.Host] call, re-validated host-side before anything is mutated.

Four rules, applied without exception:

1. Host-owned by default.

// Money is host-authoritative: a client that could write this could print money.
[Sync(SyncFlags.FromHost)] public int Money { get; private set; }

Plain [Sync] is permitted only where the owning client is genuinely authoritative — chiefly its own input and view intent — and each use carries a comment justifying it. Anything a player could profit from forging (money, inventory, permissions, ownership, arrest state, warrants, job) is FromHost, always.

2. Requests are typed, and validated host-side.

[Rpc.Host]
public void RequestPurchaseDoor(Guid doorId)
{
    // Never trust the caller. Re-check identity, existence, distance, state and funds.
    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
}

Client-side checks exist only to grey out buttons. They are advisory and are never the gate.

3. Replicate state on change; send events as events.

Kind Mechanism
Durable state (money, hunger, lock status, owner) [Sync] property
A moment in time (chat line, notification, gunshot) RPC
Bulk data (help text, laws, container contents) Sent once, on demand

Nothing is polled. A timer that re-sends unchanged data is a defect, not an optimisation.

4. Private state replicates only to its owner. A character's inventory, money and needs are on a player-owned object and do not go to everyone.

The declared budget, and the concrete conventions, are in Architecture/07_NETWORKING.md.

Alternatives considered

Client-authoritative with server reconciliation

Clients simulate and the host corrects. Lower latency for the acting player.

Rejected. This is a roleplay framework whose entire economy runs on scarcity and trust; optimistic client authority over money, inventory or arrest state is an invitation. The latency benefit is real but applies mainly to movement and shooting, where the engine already handles prediction, and not to the inventory-and-property operations that make up most of this game's interactions.

Trust the client, detect cheating afterwards

Simplest, and defensible on a small friends-only server.

Rejected: the original's community was public and multi-server. Detection after the fact still requires the validation logic, and then additionally requires a rollback story.

An authoritative dedicated server only, no host mode

Simpler to reason about — one trust model.

Rejected because S&box supports both and host mode is how most people will first play. The FromHost model covers both: in host mode the host player is the authority, which is the same trust assumption as a dedicated server plus trusting the host, and that is a documented property rather than a surprise.

Consequences

Positive

  • An entire class of exploit is structurally prevented; typed RPC parameters remove the parsing bugs for free.
  • Bandwidth is proportional to activity, not to player count.
  • No desync-until-relog: [Sync] state converges.
  • One transport instead of four, with no hand-rolled serialisation at call sites.
  • Both host mode and dedicated servers work from the same code.

Negative

  • Every client action costs a round trip, so the UI must show pending states rather than assuming success. This is the main user-visible cost.
  • Validation logic is written twice — advisory on the client for UI, authoritative on the host. Mitigated by putting the rule in a shared engine-agnostic type that both call, which the testing standard already requires.
  • Host-side validation is easy to forget in a new RPC, so it needs the review checklist to catch it.
  • Host migration is out of scope; if the host leaves, the session ends.

Neutral

  • The host is trusted. In host mode that means trusting a player — stated plainly in the operator documentation, not hidden.
  • Every [Sync] needs a deliberate decision about who may write it, which is friction by design.

Compliance

  • Review checklist item: every new [Rpc.Host] method is walked against the exploit checklist in Standards/03_TESTING_STANDARDS.md.
  • Every plain [Sync] (non-FromHost) property carries a justifying comment; absence is a review rejection.
  • Validation rules live in engine-agnostic types and are unit-tested independently of the RPC.
  • Per-milestone: measure bytes/second per player at idle. It should be near zero. A non-zero idle cost means something is polling.