Multiplayer Architecture
Core rule: the server is authoritative. Clients predict locally and reconcile when the server corrects them.
All networked state lives in explicit state structs — never in loose fields.
High-Level Architecture
flowchart TB
subgraph S[" SERVER "]
direction LR
S1[Input Queue] --> S2[Simulate] --> S3[Snapshot] --> S4[Broadcast]
end
S4 -- "state + corrections" --> C1
subgraph C[" CLIENT "]
direction LR
C1[Collect Input] --> C2[Predict] --> C3[Receive] --> C4[Reconcile] --> C5[Render]
end
NetworkMode Flags
namespace PixelEngine.Networking
{
[Flags]
public enum NetworkMode
{
None = 0,
Client = 1 << 0, // Receives state, sends input
Server = 1 << 1, // Authoritative simulation
Host = Client | Server, // Listen server
DedicatedServer = 1 << 2, // Headless server, no local player
}
}
| Mode |
Simulates |
Renders |
Accepts Input |
Sends State |
| Client |
Predict only |
Yes |
Local |
No |
| Server |
Yes |
No |
Remote |
Yes |
| Host |
Yes |
Yes |
Local + Remote |
Yes |
| DedicatedServer |
Yes |
No |
Remote |
Yes |
Dual-State Pattern
Every networked entity stores two state structs to separate concerns.
| State |
Scope |
Replicated |
Typical Contents |
| GlobalState |
Visible to all clients |
Yes |
Position, health, status effects, team ID |
| InternalState |
Server + owning client only |
No |
Cooldown timers, ammo counts, ability charges |
namespace PixelEngine.Networking
{
public struct GlobalState : INetworkState
{
public Vector3 Position;
public Quaternion Rotation;
public int Health;
public byte StatusFlags;
}
public struct InternalState : INetworkState
{
public float CooldownTimer;
public int ResourceCount;
public byte AbilityCharges;
}
}
Rule: never read InternalState on a remote proxy — it is not replicated.
Network Tick Pipeline
flowchart LR
A["1. INPUT\nCOLLECTION"] --> B["2. FIXED\nSIMULATION"] --> C["3. STATE\nSYNC"] --> D["4. PRESENTATION"]
| Stage |
Where |
What |
| Input Collection |
Client, before FixedUpdate |
Read input devices, write to InputPayload |
| Fixed Simulation |
FixedUpdate |
Apply input, advance physics, mutate state |
| State Sync |
After simulation |
Server broadcasts GlobalState snapshots |
| Presentation |
Update / LateUpdate |
Interpolate, animate, render |
NetworkExecutionArgs
Passed into every networked system's tick method. Carries everything the system needs without global lookups.
namespace PixelEngine.Networking
{
public readonly struct NetworkExecutionArgs
{
public readonly int Tick;
public readonly float DeltaTime;
public readonly NetworkMode Mode;
public readonly ExecutionMode Execution;
public readonly bool IsResimulation;
public readonly INetworkRunner Runner;
}
}
public void NetworkTick(NetworkExecutionArgs args)
{
if (args.IsResimulation)
return; // skip VFX / audio on re-sim
if (args.Execution.HasFlag(ExecutionMode.Simulation))
Simulate(args.DeltaTime);
}
Rollback & Reconciliation
How It Works
flowchart LR
T10["T=10"] --> T11["T=11"] --> T12["T=12"] --> T13["T=13"] --> T14["T=14\n(predicted)"]
T12 -.->|"Server says\nstate differs"| R["Rollback"]
R -->|"1. Restore server state at T=12\n2. Re-simulate T=12→T=14\n3. Continue from corrected state"| T14
RollbackBuffer<T>
namespace PixelEngine.Networking
{
public class RollbackBuffer<T> where T : struct, INetworkState
{
private readonly T[] _buffer;
private readonly int _capacity;
public RollbackBuffer(int capacity)
{
_capacity = capacity;
_buffer = new T[capacity];
}
/// <summary>Store state for a given tick.</summary>
public void Save(int tick, in T state)
=> _buffer[tick % _capacity] = state;
/// <summary>Retrieve state for a given tick.</summary>
public ref readonly T Load(int tick)
=> ref _buffer[tick % _capacity];
/// <summary>Check if server state matches predicted state.</summary>
public bool NeedsRollback(int tick, in T serverState)
=> !_buffer[tick % _capacity].Equals(serverState);
}
}
Reconciliation Flow
| Step |
Action |
| 1 |
Receive authoritative state for tick T from server |
| 2 |
Compare with RollbackBuffer.Load(T) |
| 3 |
If mismatch → restore server state at T |
| 4 |
Re-apply stored inputs from T+1 to current tick |
| 5 |
Resume normal prediction |
Interpolation for Remote Players
Remote entities do not predict. They interpolate between two received snapshots.
// Remote entity — render buffer is N ticks behind
int renderTick = currentTick - interpolationDelay;
ref readonly var from = ref stateBuffer.Load(renderTick);
ref readonly var to = ref stateBuffer.Load(renderTick + 1);
float alpha = SimulationTime.updateInterpolationAlpha;
transform.position = Vector3.Lerp(from.Position, to.Position, alpha);
transform.rotation = Quaternion.Slerp(from.Rotation, to.Rotation, alpha);
| Entity Type |
Technique |
Latency |
| Local player |
Client-side prediction |
~0 (instant) |
| Remote player |
Snapshot interpolation |
Render delay (2–3 ticks) |
| Server entity |
Authoritative, no interpolation |
0 |
Network Lifecycle Callbacks
namespace PixelEngine.Networking
{
public interface INetworkCallbacks
{
/// <summary>Called when entity is spawned on the network.</summary>
void OnNetworkSpawn(NetworkExecutionArgs args);
/// <summary>Called every fixed network tick.</summary>
void OnNetworkTick(NetworkExecutionArgs args);
/// <summary>Called when entity is despawned from the network.</summary>
void OnNetworkDespawn(NetworkExecutionArgs args);
/// <summary>Called when authority changes (e.g., host migration).</summary>
void OnAuthorityChanged(bool hasAuthority);
}
}
| Callback |
When |
Use For |
OnNetworkSpawn |
Entity first appears on network |
Initialize state, subscribe to events |
OnNetworkTick |
Every fixed network tick |
Core simulation logic |
OnNetworkDespawn |
Entity removed from network |
Cleanup, unsubscribe |
OnAuthorityChanged |
Authority transfers |
Enable/disable prediction |
Client vs Server Frame Comparison
flowchart TD
subgraph CL[" CLIENT "]
direction TB
CI[Read Input] --> CP[Predict]
CP --> CR[Receive State]
CR --> CRR{Match?}
CRR -->|No| CRB[Rollback]
CRR -->|Yes| CV[Render]
CRB --> CV
end
subgraph SV[" SERVER "]
direction TB
SI[Receive Inputs] --> SS[Simulate]
SS --> SB[Broadcast State]
end
CI -. "input" .-> SI
SB -. "snapshots" .-> CR
Quick Rules
| # |
Rule |
| 1 |
Server is always authoritative — clients predict, never dictate. |
| 2 |
All networked state lives in typed structs (GlobalState, InternalState). |
| 3 |
Never read InternalState on remote proxies. |
| 4 |
Always save predicted state to RollbackBuffer before advancing. |
| 5 |
Skip VFX / audio during re-simulation (args.IsResimulation). |
| 6 |
Remote entities use interpolation, not prediction. |
| 7 |
Stamp every input with its tick number. |
| 8 |
Validate all client input on the server — never trust the client. |
Unity Documentation