Introduction: The Multiplayer Challenge
Series Overview: This is Part 12 of our 16-part Unity Game Engine Series. Multiplayer networking is widely considered the hardest problem in game development. We'll demystify it by covering networking models, Unity's Netcode for GameObjects, synchronization strategies, and the real-world challenges of building networked games.
1
Unity Basics & Interface
Editor overview, assets, prefabs, architecture
2
C# Scripting Fundamentals
MonoBehaviour, coroutines, input systems, patterns
3
GameObjects & Components
Transforms, renderers, custom components
4
Physics & Collisions
Rigidbody, colliders, raycasting, forces
5
UI Systems
Canvas, uGUI, UI Toolkit, responsive design
6
Animation & State Machines
Animator, blend trees, IK, Timeline
7
Audio & Visual Effects
AudioSource, particles, VFX Graph, post-processing
8
Building & Publishing
Build pipeline, optimization, platforms, monetization
9
Rendering Pipelines
URP, HDRP, Shader Graph, lighting systems
10
Data-Oriented Tech Stack
ECS, Jobs System, Burst Compiler
11
AI & Gameplay Systems
NavMesh, FSMs, behavior trees, procedural gen
12
Multiplayer & Networking
Netcode, RPCs, latency, prediction
You Are Here
13
Tools & Editor Scripting
Custom editors, debug tools, CI/CD
14
Architecture & Clean Code
Service locators, DI, ScriptableObject architecture
15
Performance Optimization
CPU/GPU profiling, memory, object pooling
16
Production & Industry Practices
Git, Agile, asset pipelines, debugging at scale
Multiplayer networking is the art of making geographically separated players feel like they're in the same room. The fundamental challenge is that the speed of light imposes a minimum latency of ~30ms even between nearby cities, and real-world internet adds routing hops, packet loss, and jitter on top of that. Every multiplayer game is, in essence, a distributed systems problem wrapped in a fun experience.
The history of multiplayer gaming stretches from the LAN parties of the 1990s (Doom, Quake) through the early internet era (EverQuest, StarCraft on Battle.net) to today's global matchmaking services handling millions of concurrent players. The core problems have remained constant: how do you keep everyone in sync, make the game feel responsive, and prevent cheating?
Key Insight: There is no "correct" multiplayer architecture. Every model is a trade-off between responsiveness (how fast does the game feel?), consistency (does everyone see the same thing?), and security (can players cheat?). The right choice depends on your game's genre, player count, and competitive requirements.
A Brief History of Multiplayer Gaming
| Era |
Technology |
Milestone |
| 1993 |
Peer-to-peer LAN |
Doom: 4-player deathmatch over IPX/SPX |
| 1996 |
Client-server, QuakeWorld prediction |
Quake: first client-side prediction for internet play |
| 2001 |
Dedicated servers, server-side hit detection |
Counter-Strike 1.6: authoritative server model |
| 2007 |
Lag compensation, tick-rate systems |
Halo 3: advanced netcode for console shooters |
| 2015 |
Rollback netcode, GGPO |
Fighting games adopt rollback for fair online play |
| 2020s |
Cloud gaming, relay services, EOS |
Unity Netcode, Epic Online Services, global matchmaking |
Case Study
Rocket League: 60Hz Physics Over the Internet
Rocket League by Psyonix runs its physics simulation at 120Hz on the server and sends state snapshots to clients at 60Hz. The game uses client-side prediction for the local car, server reconciliation to correct mispredictions, and interpolation for remote players. Since the game is entirely physics-driven (ball trajectory, car collisions), even tiny desynchronizations are visible. Psyonix solved this with aggressive snapshot interpolation and prediction correction that blends the corrected position over several frames rather than snapping, creating the illusion of a perfectly synchronized physics world even at 80ms+ ping.
120Hz Server
Client Prediction
Smooth Correction
Physics Sync
1. Networking Models
1.1 Client-Server Architecture
In a client-server model, one machine (the server) is the authoritative source of truth. All game state changes must be validated by the server, and clients receive state updates. This is the industry standard for competitive games because it provides a single point of authority that prevents most forms of cheating.
| Variant |
How It Works |
Best For |
| Authoritative (no prediction) |
Client sends input, waits for server response |
Turn-based games, strategy, card games |
| Authoritative + Prediction |
Client predicts locally, server corrects mispredictions |
FPS, action games, platformers |
| Listen Server |
One player's machine acts as both client and server |
Small-scale multiplayer, co-op games |
| Dedicated Server |
Headless server running on cloud infrastructure |
Competitive games, large player counts |
1.2 Peer-to-Peer & Relay Servers
In peer-to-peer (P2P), all clients communicate directly with each other without a central server. Each client runs the full game simulation. This eliminates server hosting costs but introduces challenges around trust (who resolves conflicts?) and NAT traversal (how do players behind routers connect?).
Relay servers are a hybrid approach: a lightweight server forwards packets between players without running the game simulation. This solves NAT traversal while keeping costs low. Unity Relay Service provides this out of the box.
1.3 Choosing the Right Model
| Game Type |
Recommended Model |
Reason |
| Competitive FPS |
Dedicated server + prediction |
Anti-cheat, consistent tick rate, fair play |
| Co-op PvE |
Listen server or relay |
Lower cost, cheating is less critical |
| Fighting Game |
P2P with rollback |
Minimal latency (only 1 hop), 2 players |
| MMO |
Dedicated server clusters |
Thousands of players, persistent world state |
| Mobile Casual |
Relay + authoritative validation |
Simple networking, NAT-friendly, low cost |
| Party Game |
Relay or listen server |
Low complexity, small player counts |
2. Netcode for GameObjects
Unity's official networking solution is Netcode for GameObjects (NGO). It provides NetworkManager, NetworkBehaviour, NetworkVariable, RPCs, and object spawning out of the box.
2.1 NetworkManager & NetworkBehaviour
using Unity.Netcode;
using UnityEngine;
// NetworkBehaviour is the networked version of MonoBehaviour
public class PlayerNetwork : NetworkBehaviour
{
[SerializeField] private float moveSpeed = 5f;
// NetworkVariable: automatically synced from server to all clients
private NetworkVariable<Vector3> netPosition = new NetworkVariable<Vector3>(
default,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
private NetworkVariable<int> netHealth = new NetworkVariable<int>(
100,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
public override void OnNetworkSpawn()
{
// Called when this object is spawned on the network
if (IsOwner)
{
Debug.Log("I am the local player!");
// Set up camera, input, etc.
}
// Listen for health changes
netHealth.OnValueChanged += OnHealthChanged;
}
public override void OnNetworkDespawn()
{
netHealth.OnValueChanged -= OnHealthChanged;
}
private void Update()
{
if (!IsOwner) return; // Only the owner processes input
Vector3 input = new Vector3(
Input.GetAxisRaw("Horizontal"),
0,
Input.GetAxisRaw("Vertical")
);
if (input.sqrMagnitude > 0)
{
// Send movement request to server
MoveServerRpc(input.normalized);
}
}
private void OnHealthChanged(int oldValue, int newValue)
{
Debug.Log($"Health changed: {oldValue} -> {newValue}");
// Update health bar UI...
}
}
2.2 ServerRpc & ClientRpc
RPCs (Remote Procedure Calls) are the primary way clients and servers communicate specific actions. ServerRpc flows from client to server; ClientRpc flows from server to all (or specific) clients.
using Unity.Netcode;
using UnityEngine;
public class PlayerCombat : NetworkBehaviour
{
[SerializeField] private float attackRange = 2f;
[SerializeField] private int attackDamage = 25;
[SerializeField] private float attackCooldown = 0.5f;
private float lastAttackTime;
private void Update()
{
if (!IsOwner) return;
if (Input.GetMouseButtonDown(0) && Time.time > lastAttackTime + attackCooldown)
{
lastAttackTime = Time.time;
// Tell the server we want to attack
AttackServerRpc();
}
}
// ServerRpc: called on a client, executed on the server
[ServerRpc]
private void AttackServerRpc()
{
// Server validates and processes the attack
// (The server is the authority; it decides if the attack hits)
Collider[] hits = Physics.OverlapSphere(transform.position, attackRange);
foreach (var hit in hits)
{
if (hit.TryGetComponent<PlayerHealth>(out var health) &&
hit.GetComponent<NetworkObject>().OwnerClientId != OwnerClientId)
{
health.TakeDamage(attackDamage);
// Notify all clients about the hit
OnAttackHitClientRpc(hit.transform.position);
}
}
}
// ClientRpc: called on the server, executed on all clients
[ClientRpc]
private void OnAttackHitClientRpc(Vector3 hitPosition)
{
// All clients play hit effect at the position
// Spawn particles, play sound, screen shake, etc.
Debug.Log($"Hit at {hitPosition}!");
}
// ServerRpc with parameters and RequireOwnership
[ServerRpc]
private void MoveServerRpc(Vector3 direction)
{
// Server validates and applies movement
Vector3 newPos = transform.position + direction * 5f * Time.deltaTime;
transform.position = newPos;
}
// ClientRpc targeting specific clients
[ClientRpc]
private void NotifyClientRpc(string message, ClientRpcParams rpcParams = default)
{
Debug.Log($"Server says: {message}");
}
}
2.3 NetworkVariable & Ownership
NetworkVariables automatically synchronize data from the server to clients. They support value change callbacks and configurable read/write permissions:
using Unity.Netcode;
using UnityEngine;
public class PlayerHealth : NetworkBehaviour
{
// Only the server can write; everyone can read
public NetworkVariable<int> Health = new NetworkVariable<int>(
100,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server
);
// Player name: set by owner, readable by all
public NetworkVariable<Unity.Collections.FixedString64Bytes> PlayerName =
new NetworkVariable<Unity.Collections.FixedString64Bytes>(
default,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Owner
);
// NetworkList for collections
public NetworkList<int> Inventory;
private void Awake()
{
Inventory = new NetworkList<int>();
}
public override void OnNetworkSpawn()
{
if (IsOwner)
{
PlayerName.Value = $"Player_{OwnerClientId}";
}
Health.OnValueChanged += (oldVal, newVal) =>
{
Debug.Log($"{PlayerName.Value} health: {oldVal} -> {newVal}");
if (newVal <= 0) HandleDeath();
};
}
// Only callable on the server (since Health is server-write)
public void TakeDamage(int amount)
{
if (!IsServer) return;
Health.Value = Mathf.Max(0, Health.Value - amount);
}
public void Heal(int amount)
{
if (!IsServer) return;
Health.Value = Mathf.Min(100, Health.Value + amount);
}
private void HandleDeath()
{
if (IsServer)
{
// Server handles respawn logic
RespawnClientRpc();
}
}
[ClientRpc]
private void RespawnClientRpc()
{
Debug.Log($"{PlayerName.Value} died! Respawning...");
}
}
Pro Tip: Use NetworkVariable for persistent state (health, score, position) and RPCs for events (shoot, jump, emote). NetworkVariables are automatically synced to late-joining clients, while RPCs are fire-and-forget, meaning late joiners miss them.
3. Synchronization
| Approach |
How It Works |
Pros |
Cons |
| State Sync |
Server sends world state snapshots to clients |
Simple, handles late joiners, flexible |
Higher bandwidth, visual smoothing needed |
| Input Sync |
Clients exchange inputs, all run deterministic simulation |
Low bandwidth, perfect sync if deterministic |
Requires deterministic engine, hard to handle late join |
3.2 Client-Side Prediction & Server Reconciliation
Client-side prediction is the key technique that makes networked games feel responsive. Instead of waiting for the server to confirm each action, the client immediately predicts the result locally. When the server's authoritative response arrives, the client corrects any misprediction.
using Unity.Netcode;
using UnityEngine;
using System.Collections.Generic;
public class PredictedPlayerMovement : NetworkBehaviour
{
[SerializeField] private float moveSpeed = 8f;
[SerializeField] private CharacterController controller;
// Buffer of predicted inputs for reconciliation
private struct InputPayload
{
public uint Tick;
public Vector3 Direction;
}
private struct StatePayload
{
public uint Tick;
public Vector3 Position;
}
private Queue<InputPayload> inputBuffer = new Queue<InputPayload>();
private uint currentTick = 0;
private const int BUFFER_SIZE = 1024;
// Server-confirmed state
private NetworkVariable<StatePayload> serverState = new NetworkVariable<StatePayload>();
private StatePayload[] predictionBuffer = new StatePayload[BUFFER_SIZE];
public override void OnNetworkSpawn()
{
serverState.OnValueChanged += OnServerStateChanged;
}
private void Update()
{
if (!IsOwner) return;
currentTick++;
Vector3 input = new Vector3(
Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical")
).normalized;
// 1. Apply input locally (prediction)
controller.Move(input * moveSpeed * Time.deltaTime);
// 2. Store prediction for later reconciliation
predictionBuffer[currentTick % BUFFER_SIZE] = new StatePayload
{
Tick = currentTick,
Position = transform.position
};
// 3. Send input to server
SendInputServerRpc(new InputPayload { Tick = currentTick, Direction = input });
}
[ServerRpc]
private void SendInputServerRpc(InputPayload input)
{
// Server applies input authoritatively
controller.Move(input.Direction * moveSpeed * Time.fixedDeltaTime);
// Broadcast authoritative state
serverState.Value = new StatePayload
{
Tick = input.Tick,
Position = transform.position
};
}
private void OnServerStateChanged(StatePayload oldState, StatePayload newState)
{
if (!IsOwner) return;
// Compare server state with our prediction
StatePayload predicted = predictionBuffer[newState.Tick % BUFFER_SIZE];
float error = Vector3.Distance(predicted.Position, newState.Position);
if (error > 0.01f)
{
// Misprediction! Snap to server position and re-simulate
Debug.Log($"Reconciliation: error={error:F3}m at tick {newState.Tick}");
transform.position = newState.Position;
// Re-apply all inputs since the corrected tick
// (This is the reconciliation step)
}
}
}
3.3 Lag Compensation & Interpolation
Snapshot interpolation smooths remote player movement by rendering them slightly in the past (typically 100-200ms) and interpolating between received snapshots. This eliminates jitter from irregular packet delivery.
using UnityEngine;
using System.Collections.Generic;
public class NetworkInterpolation : MonoBehaviour
{
[SerializeField] private float interpolationDelay = 0.1f; // 100ms behind
private struct Snapshot
{
public float Timestamp;
public Vector3 Position;
public Quaternion Rotation;
}
private List<Snapshot> snapshotBuffer = new List<Snapshot>();
private float renderTimestamp;
public void ReceiveSnapshot(Vector3 position, Quaternion rotation, float serverTime)
{
snapshotBuffer.Add(new Snapshot
{
Timestamp = serverTime,
Position = position,
Rotation = rotation
});
// Keep buffer trimmed (max 30 snapshots)
while (snapshotBuffer.Count > 30)
snapshotBuffer.RemoveAt(0);
}
private void Update()
{
renderTimestamp = Time.time - interpolationDelay;
// Find the two snapshots surrounding our render time
for (int i = 0; i < snapshotBuffer.Count - 1; i++)
{
if (snapshotBuffer[i].Timestamp <= renderTimestamp &&
snapshotBuffer[i + 1].Timestamp >= renderTimestamp)
{
float t = Mathf.InverseLerp(
snapshotBuffer[i].Timestamp,
snapshotBuffer[i + 1].Timestamp,
renderTimestamp
);
// Interpolate between snapshots
transform.position = Vector3.Lerp(
snapshotBuffer[i].Position,
snapshotBuffer[i + 1].Position, t
);
transform.rotation = Quaternion.Slerp(
snapshotBuffer[i].Rotation,
snapshotBuffer[i + 1].Rotation, t
);
return;
}
}
// If no valid pair found, extrapolate from last known
if (snapshotBuffer.Count > 0)
{
transform.position = snapshotBuffer[snapshotBuffer.Count - 1].Position;
}
}
}
Critical Concept: Lag compensation is essential for hit detection in shooters. When a player fires, the server must rewind all other players to where they were at the shooter's render time (accounting for latency), check the hit, then restore the current state. Without this, high-ping players could never hit moving targets because by the time their shot reaches the server, the target has already moved.
4. Multiplayer Challenges
4.1 Latency & Bandwidth Optimization
| Metric |
Good |
Acceptable |
Poor |
| RTT (Round Trip Time) |
< 50ms |
50-150ms |
> 150ms |
| Packet Loss |
< 1% |
1-3% |
> 3% |
| Jitter |
< 10ms variance |
10-30ms |
> 30ms |
| Bandwidth per player |
< 5 KB/s |
5-20 KB/s |
> 20 KB/s |
Bandwidth optimization techniques include:
- Delta compression: Only send values that changed since the last snapshot
- Quantization: Reduce float precision (e.g., 2 bytes instead of 4 for position)
- Interest management: Only send data about nearby objects (area of interest)
- Variable tick rate: Send important objects more frequently than background objects
- Bit packing: Pack boolean flags into single bytes
4.2 Cheating Prevention & Security
Golden Rule of Multiplayer Security: Never trust the client. Validate everything on the server. If the client says "I moved 100 units this frame," the server should check if that's physically possible within the time elapsed. If the client says "I hit the enemy," the server should verify line of sight and range.
using Unity.Netcode;
using UnityEngine;
public class ServerValidation : NetworkBehaviour
{
private const float MAX_SPEED = 10f;
private const float SPEED_TOLERANCE = 1.2f; // 20% tolerance for lag
private Vector3 lastValidatedPosition;
private float lastValidationTime;
[ServerRpc]
public void MoveRequestServerRpc(Vector3 requestedPosition)
{
if (!IsServer) return;
float timeDelta = Time.time - lastValidationTime;
float maxDistance = MAX_SPEED * timeDelta * SPEED_TOLERANCE;
float actualDistance = Vector3.Distance(lastValidatedPosition, requestedPosition);
if (actualDistance > maxDistance)
{
// Possible speed hack! Reject and snap back
Debug.LogWarning($"Client {OwnerClientId}: speed violation " +
$"(moved {actualDistance:F1}m, max {maxDistance:F1}m)");
CorrectPositionClientRpc(lastValidatedPosition);
return;
}
// Validate: is the position inside valid bounds?
if (!IsPositionValid(requestedPosition))
{
Debug.LogWarning($"Client {OwnerClientId}: invalid position");
CorrectPositionClientRpc(lastValidatedPosition);
return;
}
// Accept the move
lastValidatedPosition = requestedPosition;
lastValidationTime = Time.time;
transform.position = requestedPosition;
}
private bool IsPositionValid(Vector3 pos)
{
// Check bounds, collision, no-clip, etc.
return pos.y >= 0 && pos.y < 100 &&
!Physics.CheckSphere(pos, 0.3f, LayerMask.GetMask("Walls"));
}
[ClientRpc]
private void CorrectPositionClientRpc(Vector3 correctedPosition)
{
if (IsOwner) transform.position = correctedPosition;
}
}
4.3 Transport Layer & Protocols
| Protocol |
Reliability |
Speed |
Use In Games |
| TCP |
Guaranteed delivery, ordered |
Slower (retransmission delays) |
Chat, inventory, matchmaking, login |
| UDP |
No guarantee, unordered |
Fast (no retransmission overhead) |
Position updates, game state, real-time data |
| Reliable UDP |
Selective reliability, sequenced channels |
Best of both worlds |
Most game networking (Unity Transport default) |
| WebSocket |
Reliable, TCP-based, browser-compatible |
Moderate |
WebGL games, browser-based multiplayer |
Case Study
Among Us: Simple Networking Done Right
Among Us by InnerSloth (built in Unity) demonstrates that simple networking is often best. The game uses a client-server model with the host acting as server. Since the game is slow-paced (no real-time physics, no twitch shooting), it can tolerate higher latency. Movement is synced at a low rate, and most game-critical actions (voting, killing, tasks) are discrete events handled as reliable RPCs. The lesson: match your networking complexity to your gameplay needs. Among Us works perfectly without client-side prediction, lag compensation, or 60Hz tick rates.
Simple Architecture
Event-Driven
Host-as-Server
Latency Tolerant
Case Study
Fall Guys: Server Architecture at Scale
Fall Guys by Mediatonic handles 60 players per match with a dedicated server architecture. The game runs physics authoritatively on the server, which is critical because player-vs-player collisions determine who wins. Clients send inputs and receive state snapshots with interpolation. The servers are hosted on cloud infrastructure (originally AWS, later Epic's backend) that auto-scales based on player demand. During the game's launch, traffic surged to over 1.5 million concurrent players, requiring aggressive horizontal scaling and regional server deployment across multiple continents.
60 Players
Authoritative Physics
Cloud Scaling
Regional Servers
Exercises & Self-Assessment
Exercise 1
Basic Multiplayer Lobby
Set up a minimal multiplayer game with Netcode for GameObjects:
- Install the Netcode for GameObjects package from Package Manager
- Create a NetworkManager with a player prefab (cube + NetworkObject + NetworkBehaviour)
- Implement Host/Join buttons using NetworkManager.Singleton.StartHost() and StartClient()
- Sync player position using NetworkVariable or NetworkTransform
- Add a chat system using ServerRpc (send message) and ClientRpc (broadcast to all)
- Display connected player names using NetworkVariable<FixedString64Bytes>
Exercise 2
Authoritative Server Validation
Build a movement system with server-side cheat prevention:
- Client sends movement input to the server via ServerRpc
- Server validates: speed check, boundary check, collision check
- Server applies validated movement and syncs via NetworkVariable
- Intentionally try to "cheat" by sending invalid movement and verify the server rejects it
- Add a server-side log that flags suspicious movement patterns
Exercise 3
Snapshot Interpolation Visualizer
Build a visual tool that demonstrates network interpolation:
- Create a "remote player" that receives position snapshots at simulated intervals (5-20 Hz)
- Implement snapshot interpolation with a configurable delay (50-200ms)
- Add a "no interpolation" mode to see raw jittery updates
- Add a "extrapolation" mode for when snapshots arrive late
- Visualize: server position (green), predicted position (blue), interpolated position (red)
Exercise 4
Reflective Questions
- Why do fighting games prefer P2P with rollback over client-server? What unique requirements does the fighting game genre have?
- Explain the difference between NetworkVariable and RPCs. When would you use each? Give three examples of each.
- A player reports that their character "rubberbands" (snaps back to a previous position). What causes this and how would you mitigate it?
- Design the networking architecture for a 100-player battle royale. What model would you use? How would you handle bandwidth with 100 players?
- Why is UDP preferred over TCP for real-time game data? What problems does TCP's guaranteed delivery cause in a fast-paced game?
Conclusion & Next Steps
Multiplayer networking is the most challenging aspect of game development, but with the right knowledge and tools, it's entirely manageable. Here are the key takeaways from Part 12:
- Client-server authoritative is the industry standard for competitive games; the server validates all game state and clients trust the server's authority
- Netcode for GameObjects provides NetworkManager, NetworkBehaviour, NetworkVariable, and RPCs (ServerRpc/ClientRpc) for building networked games in Unity
- NetworkVariables are for persistent state (auto-synced to late joiners); RPCs are for events (fire-and-forget)
- Client-side prediction makes games feel responsive by predicting locally and reconciling with server corrections
- Snapshot interpolation smooths remote player movement by rendering 100-200ms behind and lerping between snapshots
- Lag compensation rewinds the world to the shooter's perspective time for fair hit detection
- Never trust the client: validate all inputs on the server to prevent speed hacks, teleport hacks, and damage manipulation
- UDP is preferred for real-time game data; TCP is used for reliable events like chat and matchmaking
Next in the Series
In Part 13: Tools & Editor Scripting, we'll build the tools that make you productive: custom inspectors, editor windows, scene visualization tools, automated build pipelines, CI/CD with GitHub Actions, and the Unity Test Framework.
Continue the Series
Part 13: Tools & Editor Scripting
Build custom editors, debug visualization tools, automated build pipelines, and CI/CD workflows for professional Unity development.
Read Article
Part 14: Architecture & Clean Code
Service locators, dependency injection, ScriptableObject architecture, and design patterns for scalable Unity projects.
Read Article
Part 11: AI & Gameplay Systems
NavMesh pathfinding, FSMs, behavior trees, procedural generation, quest systems, and inventory management.
Read Article