Unity Workflow LibraryUWLAbout

Topic 6 · Networking

Mirror networking

Logic/presentation split and authority rules for Mirror.

Same logic↔presentation split as unity.md, specialized for Mirror. Authority/data lives in a networked system; the view is a passive UI_ subscriber. Full annotated chain: examples/system-ui-split.md.

Roles

  • System / authority — Server_X (or Service_X) : NetworkBehaviour. Owns state + game logic. Server-only mutations ([Server]/isServer guards). Singleton (Instance) is fine.
  • View — UI_X : MonoBehaviour. Holds element refs ([SerializeField]), subscribes to the system's events, drives child widgets. No game logic, no direct state mutation.

Direction of flow

  • Client → server (commands): define a struct … : NetworkMessage; the server registers it in OnStartServer via NetworkServer.RegisterHandler<TMessage>(handler), and the view sends it with NetworkClient.Send(message). Prefer messages over [Command] so plain (non-NetworkBehaviour) UI scripts can send directly.
  • Server → clients (results): [ClientRpc] to broadcast to all, [TargetRpc] to one client.
  • RPC → view: the RPC body fires a public static event Action<…>. Views subscribe to that event, never to the RPC method.

Event naming

Suffix the event by where it fires so subscribers know the context: _Rpc (from a ClientRpc — all clients) · _Target (from a TargetRpc — one client) · _Server (server-side only) · _Client (local client only). e.g. OnEmoteExecuted_Rpc, OnNetworkPlayerConnected_Server.

Lifecycle

  • Server registers/unregisters handlers in OnStartServer / OnStopServer.
  • Views subscribe in OnEnable, unsubscribe in OnDisable. Static events survive scene reloads — skip the unsubscribe and you leak handlers + double-fire.

A message's SHAPE is the wire — changing fields kicks every older client (HARD)

Mirror derives a message's id from the type name, not from its fields. So an old build and a new build agree on the id and disagree on the payload: the reader runs off the end of the buffer, throws inside the handler, and exceptionsDisconnect (default true on both NetworkServer and NetworkClient) drops whoever sent it. There is no version error, no log on the sender, and no mismatch warning — just a disconnect the moment the feature is first used.

  • Adding, removing, reordering or retyping a field in a struct … : NetworkMessage is a breaking wire change. Same for changing an [ClientRpc]/[Command] parameter list.
  • The blast radius is "every device not yet updated", so it lands during exactly the window a staged rollout creates. Ship the wire change to all devices before the feature that uses it, or version-gate it (new type name for the new shape, keep the old handler until the old build is dead).
  • It reads as a gameplay bug, not a protocol bug. In the case that produced this rule an emote message went from {PlayerIndex, EmoteIndex} to {EmoteId}; the symptom presented as "the emoji with no animation crashed the match", because the ids lacking art happened to be the same ids that ran past the end of the old build's array.
  • Single-player / host-only testing cannot see any of this: one build talking to itself always agrees with itself. A wire change is only tested across two devices.

Message handlers and RPC bodies are disconnect boundaries — fail soft (HARD)

An exception escaping a handler is not a logged error, it is an eviction:

boundary who gets dropped
NetworkServer.RegisterHandler<T> handler the sender
[ClientRpc] / [TargetRpc] body (incl. anything a subscriber does) the receiver

So any RPC that fans out to public static event Action<…> must catch at the RPC, not in each subscriber — one catch covers every present and future listener, and a view added later cannot reintroduce the hazard. Log with a logger that is not stripped in release (Debug.LogError, not a [Conditional] wrapper): a silent disconnect on a device nobody can attach a debugger to has no other symptom.

This matters most for the features nobody treats as critical. Emotes, cosmetics, chat and reactions are the ones written without null checks — and they are just as capable of ending a match as the card logic.

  • HOST-RPC-THROW-IS-A-MIGRATION-LOOP — never index a replicated collection by an index THIS client owns. Authored seat arrays, a cached playerIndex, localIndex — each keeps whatever the roster last handed it and OUTLIVES that roster, reliably after a rebuild from a checkpoint. Check the index against THAT payload's Count every time and skip, never throw. On a plain client the throw is a kick; on the HOST it is not a crash: Mirror drops the host's local client, the reconnection layer reads an involuntary disconnect, promotes, reseeds from the checkpoint and replays the same round end — the failing operation is the one recovery replays, so it loops forever, burning a migration generation and a relay endpoint per lap. (a mobile card game: PlayerScores[seat.GetPlayerIndex] → generations 3→4→5 on an ~18 s cycle, "finding new host over and over". The emote RPCs had carried this catch since a live kick eight days earlier; the lesson was never swept across the other 44 RPC boundaries — SWEEP-THE-SET-NOT-THE-INSTANCE.)

Transport timeout: verify the driver, not the inspector (HARD)

An authored TimeoutMS on a transport component is not evidence of the live disconnect budget. In the widely-vendored Unity Transport (UTP) transport for Mirror, UtpServer.Start and UtpClient.RelayConnect both build a NetworkSettings carrying disconnectTimeoutMS and then construct the driver from a different, freshly-allocated NetworkSettings — the configured value is discarded. Only the direct-IP UtpClient.Connect path honours it, and a project using Relay never takes that path. The real budget is NetworkParameterConstants.DisconnectTimeoutMS (30 s), regardless of what the inspector says.

Two consequences, both load-bearing:

  • Never quote an authored timeout in an analysis. Read the driver construction. A 10 s value in the scene with a 30 s effective budget changes every conclusion about backgrounding, ads, phone calls and app-switch survival by 3×.
  • Repairing the plumbing is a silent regression. Wiring the discarded settings through "correctly" cuts the live budget from 30 s to whatever stale number sits in the inspector. Raise the authored value in the same commit, or don't touch it.

Generalises past UTP: any transport that separates "settings object I configured" from "settings object the driver was created with" has this shape. Grep for the driver-creation call and check which instance it receives.

Fullscreen native overlays background the player loop

A rewarded/interstitial ad, an OS share sheet, or an IAP dialog on Android runs in its own Activity — the Unity player loop stops, so Mirror's transport (which ticks from the injected NetworkEarlyUpdate in the PlayerLoop) stops pumping. A 45 s ad against a 30 s disconnect budget is a guaranteed drop, and if the host is the one backgrounded, every remote client times out even though none of them did anything.

  • Gate any disconnect-triggered scene bounce on match state you already replicate (an EndScreenActive/resultsApplied style predicate), not on an "overlay in flight" flag. A per-device overlay flag cannot see the collateral victims, and a process-wide static latch that is only cleared by a callback becomes a permanent wedge the first time that callback is lost.
  • Any suppression must also resume: OnClientDisconnect fires exactly once. Suppress it without draining a deferred bounce and you trade an unexplained eviction for an unexplained limbo (network dead, lobby seat removed, scene still saying "in a match").

ACTOR-FROM-CONNECTION — derive who is acting from the connection, never the payload

A network message must never carry the sender's own seat/player index. Check three things, in order:

  1. Who — derive the actor from conn, not from the payload. A client trusted to name its own seat can name anyone else's (a mobile card game shipped a free puppet-anyone's-face button).
  2. What — the id exists in the catalogue.
  3. How often — a per-connection floor. The rate limit must be SERVER-side; a modified client will not rate-limit itself.

Transport-agnostic — the same law holds for NetCode RPCs and any HLAPI.

A-BORROWED-STATE-IS-NOT-AN-OPEN-PHASE — gate authority on the phase owner, never the state value (HARD)

Server coroutines often set a phase's state value early so clients render the lead-in (a shuffle, a reveal) before the phase's owner is chosen. Every handler gated only on state == WaitingOnX is then open during that lead-in, and a per-phase owner field that is never cleared still names LAST phase's owner — so that player's delayed or duplicated message is accepted for the wrong phase. The same gap reaches server timers that tick in that state on the previous turn's leftover clock.

  • The owner field is the phase marker. One writer opens it (the phase-begin call site), one terminal closes it to the sentinel before the turn is handed on. Gate every handler AND every timeout/disconnect branch on state + open owner.
  • A not-found sentinel matches a closed sentinel. GetSeatFromConnection returning -1 for a seatless connection passes sender != owner when the closed phase is also -1. Put seat >= 0 && seat == owner in ONE predicate every gate calls.
  • Symptom: the phase runs twice (two round ends, two payouts) from one extra packet, and never through the normal UI, which hides the button during the lead-in. Test by sending the intent from the previous owner during the lead-in and forcing the phase timer to 0 there.