---
title: "Mirror networking"
slug: /workflows/mirror/
markdown: /workflows/mirror.md
section: netcode
tier: 2
summary: "Logic/presentation split and authority rules for Mirror."
sources: ['mirror.md']
canonical: https://unityworkflowlibrary.dev/workflows/mirror/
read_first: /read-first.md
---

> Read first, on every prompt: [non-destructive rules](/read-first.md).

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