Every lesson so far has run on one machine: one player, one memory space, one CPU deciding what is true. The moment you add a second player over the internet, that stops being true. Two computers now disagree about where a character is, which shot landed first, and who pressed a button when — and they disagree by physics, not by bad luck, because information cannot travel faster than the connection between them allows. This lesson is about the architecture games use to deal with that: who is allowed to decide what is true, what gets sent over the wire, how often, and how small you can make it. Everything here is engine-agnostic networking theory, written in C# because that is what you will use to implement it in Unity, and one section connects it to Unity's own multiplayer packages.
A singleplayer game only has to be internally consistent with itself. A multiplayer game has to keep two or more separate computers agreeing on one shared reality, over a connection that is slow, unreliable, and can carry messages from someone actively trying to cheat. Those three problems — slow, unreliable, dishonest — are the whole subject of this lesson.
Latency (also called ping) is the time it takes a message to travel from one machine to another. Even at the speed of light, a message from Bangkok to a server in Singapore takes real, measurable time — and the actual number is always worse than the speed-of-light minimum, because the message passes through routers, cables, and queues along the way. A typical online match might see 30-150 milliseconds (ms) of latency one-way. That sounds small, but a game usually runs at 60 frames per second, meaning one frame is about 16.7 ms — so 100 ms of latency is roughly 6 frames' worth of "the past" by the time information arrives.
Every design choice in this lesson — client-side prediction, snapshots, delta compression, tick rate — exists because of that one fact: information about the game world is always a little bit old by the time it reaches you, and there is a hard physical floor on how much you can shrink that delay.
Messages sent over the internet do not always arrive. Packet loss is when a message is sent but never shows up at the other end — a router got overloaded and dropped it, a wifi signal glitched, anything. Jitter is when messages that do arrive show up at uneven intervals — packet 1 might take 40 ms, packet 2 might take 90 ms, even though they were sent 16 ms apart. A game's networking code has to keep working when some fraction of messages simply vanish and the rest show up in a scrambled rhythm.
Any message a client sends can be edited before it leaves that player's machine. A "hacked client" (a modified copy of the game, or a separate program that fakes network messages) can send whatever bytes it wants. If your game design ever lets a client directly state a fact about the game world — "my health is 9999," "I am standing at this exact winning position," "I hit that enemy for 500 damage" — a cheater can simply say so, and if nothing checks it, it becomes true.
using System;
class Player
{
public int Health = 100;
}
class NaiveServer
{
// BAD: the server just believes whatever the client claims.
public void ApplyClientMessage(Player p, string message)
{
if (message.StartsWith("SET_HEALTH:"))
{
int newHealth = int.Parse(message.Substring(11));
p.Health = newHealth; // no check of any kind!
}
}
}
class Program
{
static void Main()
{
Player p = new Player();
NaiveServer server = new NaiveServer();
Console.WriteLine("Health before: " + p.Health);
// A hacked client just sends whatever it wants.
string cheatMessage = "SET_HEALTH:9999";
server.ApplyClientMessage(p, cheatMessage);
Console.WriteLine("Health after cheat message: " + p.Health);
}
}
Output:
Health before: 100
Health after cheat message: 9999
What happened: the server never questioned the message — it read a string the client sent and wrote it straight into the true game state. A real hacked client does not even need special tools to pull this off if the protocol allows it; it just sends a different string than the real game would. Section 3 fixes this properly: the server will stop believing facts from the client, and will only believe requests.
There are two basic shapes for who talks to whom in a multiplayer game.
In peer-to-peer (P2P), every player's machine connects directly to every other player's machine. There is no separate, dedicated computer coordinating things — the players' own machines are also the network.
P2P avoids paying for and running dedicated servers, and it can have lower latency between two specific players since their messages do not detour through a third machine. It shows up in some fighting games, some older shooters, and plenty of small co-op or turn-based titles. But it has real costs: with no single owner of the truth, every peer has to somehow agree on facts like "who won the race" or "who hit first," which is difficult to do fairly when peers can lie (see Section 1.3). It also means every player's connection quality affects every other player, and one player's machine leaving the match can break the whole session unless another peer takes over as "host."
In client-server, every player ("client") connects only to one central machine (the "server"). Players never talk directly to each other — every message goes through the server first.
Client-server needs a machine to run the server — that costs money, whether it is a dedicated server in a data center or one player's machine acting as a "listen server" (a server that is also playing the game). In exchange, it gives you one single place where the truth lives, one place to check whether a request makes sense, and a much smaller number of connections to manage (N players need only N connections, not roughly N-squared). This is why almost every competitive multiplayer game — shooters, MOBAs, racing games, battle royales — uses client-server, and why the rest of this lesson assumes it.
Client-server tells you the shape of the connections. Authoritative server is a rule about trust layered on top of that shape: the server is the only machine allowed to decide what is actually true about the game world. Clients do not tell the server facts ("I am at this position," "I dealt this damage"). Clients only tell the server intent — what the player is trying to do ("move right," "swing my sword") — and the server itself computes the result, using its own copy of the game's rules and physics.
This directly fixes the cheating problem from Section 1.3. A client can still send a lie, but the lie is now shaped like "I want to attack," not "I dealt 9999 damage" — and "I want to attack" is not dangerous, because the server decides the amount of damage itself, using its own rules, no matter what the client hoped for.
using System;
class PlayerState
{
public float X;
public int Health = 100;
}
// The client can only ask to move or attack.
// It can NEVER directly say "my health is now 9999".
enum InputType { MoveRight, MoveLeft, Attack }
class AuthoritativeServer
{
public PlayerState State = new PlayerState();
public void ApplyInput(InputType input)
{
switch (input)
{
case InputType.MoveRight:
State.X += 1.0f;
break;
case InputType.MoveLeft:
State.X -= 1.0f;
break;
case InputType.Attack:
// The server decides the damage, not the client.
State.Health -= 10;
break;
}
}
}
class Program
{
static void Main()
{
AuthoritativeServer server = new AuthoritativeServer();
Console.WriteLine("X=" + server.State.X + " Health=" + server.State.Health);
server.ApplyInput(InputType.MoveRight);
server.ApplyInput(InputType.MoveRight);
server.ApplyInput(InputType.Attack);
Console.WriteLine("X=" + server.State.X + " Health=" + server.State.Health);
}
}
Output:
X=0 Health=100
X=2 Health=90
What happened: the client (imagined here as whatever calls ApplyInput) never touched Health or X directly. It only named an InputType, and the server's own switch statement decided exactly how much each input was worth. Even a hacked client that sends Attack a thousand times per second only ever produces exactly 10 damage per accepted attack, because the number 10 lives on the server, not in any message the client sends.
The obvious cost of an authoritative server is Section 1.1's latency problem, now unavoidable: a player's input has to travel to the server, get simulated, and travel back before the player sees the true result — a full round trip, not just one-way. Competitive games hide most of that wait with client-side prediction (the client immediately simulates its own guess of what the server will decide, so movement feels instant, then quietly corrects itself if the server disagrees). Prediction and reconciliation are a big enough topic to deserve their own lesson later in this chapter; for now, just remember that the "instant feel" you get in a good competitive game is a trick layered on top of an authoritative server, not proof that the server was skipped.
Once you know the server needs to send and receive many small messages per second, the next question is which transport protocol (the low-level rules for moving bytes between two machines over IP) to build on. There are two common choices.
TCP (Transmission Control Protocol) guarantees two things: every message you send arrives, and it arrives in the exact order you sent it. It achieves this by having the receiving side send back acknowledgments (confirmations) for what it received, and having the sending side resend anything that was not acknowledged in time. Web pages, file downloads, and chat messages use TCP because losing or reordering a byte would be a real bug — you cannot render half a web page byte, or leave out a line of chat.
The guarantee has a cost: if message number 2 out of a stream is lost, TCP will not hand message 3, 4, or 5 to your program yet, even though they already fully arrived — because handing them over out of order would break the "in order" promise. Your program sits and waits for message 2 to be resent and confirmed, and everything behind it queues up. This is called head-of-line blocking: one lost, old message blocks every newer message behind it, even messages your game does not even care about anymore.
UDP (User Datagram Protocol) sends a message and does not wait for confirmation, does not resend anything automatically, and does not care what order messages arrive in. A message might arrive, might arrive late, might arrive out of order, or might never arrive at all — UDP promises none of that and simply does its best. In exchange, there is no head-of-line blocking: a lost or late message never holds up any other message, because UDP never waits for anything.
Most competitive real-time games send their fast-changing data — movement, aiming, shooting — over UDP, then add back only the small amount of reliability they actually need, by hand, on top. The key insight is that not all game data is equally perishable:
So instead of TCP's one-size-fits-all "everything is reliable and ordered," games build a thin reliability layer on top of UDP that can make that choice per message: some things get resent until acknowledged, most things do not. The building blocks are sequence numbers (an increasing ID number stamped on every outgoing packet) and acks (short for acknowledgments — a way for the receiver to tell the sender "here is the highest packet number I've received, plus which recent ones before that I also have").
Notice what does not happen: nobody re-sends "packet 11" as a whole packet with the same old position in it — by the time anyone would notice packet 11 was lost, a much newer position (from packet 13, 14...) is already on the way. What gets resent, if anything, is only the data inside packet 11 that truly cannot be allowed to go missing (a "player picked up the sword" event, say), tucked into the very next outgoing packet instead of position data that is already stale.
Here is a minimal version of the sequence/ack idea from Section 5, written out as C#. Every packet starts with a small header carrying three numbers: this packet's own sequence number, the highest sequence number we've received from the other side, and a bitfield recording a few more recently-received numbers before that (so a single lost ack packet does not lose the whole acknowledgment).
using System;
using System.Collections.Generic;
// Sits at the front of every UDP packet we send.
struct PacketHeader
{
public ushort Sequence; // this packet's own ID number
public ushort Ack; // highest sequence number we've received from the other side
public uint AckBits; // bit i set means "we also received packet (Ack - i)"
}
class ReliabilityLayer
{
ushort localSequence = 0;
ushort remoteSequence = 0;
uint remoteAckBits = 0;
// Packets we sent that have not been acked yet.
Dictionary<ushort, byte[]> sentButUnacked = new Dictionary<ushort, byte[]>();
public PacketHeader BuildHeader()
{
PacketHeader header;
header.Sequence = localSequence;
header.Ack = remoteSequence;
header.AckBits = remoteAckBits;
localSequence++;
return header;
}
// Called every time a packet arrives from the other side.
public void OnPacketReceived(PacketHeader header)
{
if (header.Sequence > remoteSequence)
{
int shift = header.Sequence - remoteSequence;
remoteAckBits = (remoteAckBits << shift) | 1u;
remoteSequence = header.Sequence;
}
else
{
int shift = remoteSequence - header.Sequence;
remoteAckBits |= (1u << shift);
}
// The other side just told us what it has received from us.
sentButUnacked.Remove(header.Ack);
for (int i = 0; i < 32; i++)
{
if ((header.AckBits & (1u << i)) != 0)
{
ushort ackedSeq = (ushort)(header.Ack - (i + 1));
sentButUnacked.Remove(ackedSeq);
}
}
}
}
class Program
{
static void Main()
{
ReliabilityLayer a = new ReliabilityLayer();
PacketHeader h1 = a.BuildHeader();
Console.WriteLine("Packet 1: seq=" + h1.Sequence + " ack=" + h1.Ack);
PacketHeader h2 = a.BuildHeader();
Console.WriteLine("Packet 2: seq=" + h2.Sequence + " ack=" + h2.Ack);
}
}
Output:
Packet 1: seq=0 ack=0
Packet 2: seq=1 ack=0
What happened: BuildHeader stamps each outgoing packet with the next sequence number (0, then 1, ...) and reports back the highest sequence number we've seen from the other side so far — still 0 in this trace, since OnPacketReceived was never called yet. localSequence and remoteSequence are separate counters: one tracks "how many packets have I sent," the other tracks "what's the newest packet I've gotten."
Walk through OnPacketReceived by hand for the scenario from Section 5's diagram: packets with sequence 10 and 12 arrive (11 is lost). When seq=10 arrives, header.Sequence (10) > remoteSequence (0), so remoteSequence becomes 10 and bit 0 of remoteAckBits gets set (meaning "10 itself is received"). When seq=12 arrives, 12 > 10, so remoteSequence becomes 12, and the bitfield shifts left by 2 (since 12 - 10 = 2) before setting bit 0 again — which pushes the "10 received" bit from position 0 to position 2. Reading the bitfield back: bit 0 set means "12 itself received," bit 2 set means "12 - 2 = 10 received," and bit 1 (which would mean "11 received") stays 0. That 0 at bit 1 is exactly how the receiver's outgoing ack packet reports "10 got, 11 missing" to the sender.
Sequence and Ack here are ushort (a 16-bit unsigned integer, 0 to 65535) — real implementations must handle the number wrapping back to 0 after 65535, or comparisons like header.Sequence > remoteSequence silently break the moment a match runs long enough for the counter to wrap. Production code uses wraparound-safe comparisons instead of a plain >; this lesson keeps the plain version to keep the idea readable.Sequence numbers and acks are about getting bytes there reliably. This section is about what those bytes actually contain. The simplest answer: every so often, the server packages up the entire current state of the game world into one message and sends it to every client. That message is called a snapshot.
using System;
using System.Collections.Generic;
struct EntitySnapshot
{
public int Id;
public float X;
public float Y;
public int Health;
}
struct WorldSnapshot
{
public int Tick;
public List<EntitySnapshot> Entities;
}
class Program
{
static void Main()
{
WorldSnapshot snap = new WorldSnapshot();
snap.Tick = 42;
snap.Entities = new List<EntitySnapshot>();
snap.Entities.Add(new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 100 });
snap.Entities.Add(new EntitySnapshot { Id = 2, X = -2.0f, Y = 1.5f, Health = 80 });
Console.WriteLine("Tick " + snap.Tick + " has " + snap.Entities.Count + " entities");
foreach (EntitySnapshot e in snap.Entities)
{
Console.WriteLine(" Entity " + e.Id + ": pos=(" + e.X + "," + e.Y + ") hp=" + e.Health);
}
}
}
Output:
Tick 42 has 2 entities
Entity 1: pos=(3.5,0) hp=100
Entity 2: pos=(-2,1.5) hp=80
What happened: WorldSnapshot is a plain data container — a tick number plus a list of every entity's position and health at that exact tick. A client that receives this can throw away everything it thought it knew and simply draw the world exactly as described. That is the appeal of full snapshots: they are simple and self-correcting, since each one is a complete, independent picture that does not depend on any earlier message having arrived correctly.
The problem is size. A game with 60 entities, sent 20-30 times per second, sending a full description of all 60 every single time, adds up to a lot of bytes — most of which describe things that did not change at all since the previous tick. Section 8 fixes that.
Delta compression means comparing the current snapshot to a previous one the receiver already has, and sending only the differences (the "delta") instead of the whole thing again. If an entity's position and health are identical to last tick, nothing about it needs to be sent at all this tick.
using System;
using System.Collections.Generic;
struct EntityDelta
{
public int Id;
public bool PositionChanged;
public float X;
public float Y;
public bool HealthChanged;
public int Health;
}
class Program
{
// Compares two snapshots of the same entity and keeps only what changed.
static EntityDelta MakeDelta(EntitySnapshot oldE, EntitySnapshot newE)
{
EntityDelta d = new EntityDelta();
d.Id = newE.Id;
if (oldE.X != newE.X || oldE.Y != newE.Y)
{
d.PositionChanged = true;
d.X = newE.X;
d.Y = newE.Y;
}
if (oldE.Health != newE.Health)
{
d.HealthChanged = true;
d.Health = newE.Health;
}
return d;
}
static void Main()
{
EntitySnapshot last = new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 100 };
EntitySnapshot current = new EntitySnapshot { Id = 1, X = 3.5f, Y = 0.0f, Health = 90 };
EntityDelta delta = MakeDelta(last, current);
Console.WriteLine("PositionChanged=" + delta.PositionChanged);
Console.WriteLine("HealthChanged=" + delta.HealthChanged + " newHealth=" + delta.Health);
}
}
Output:
PositionChanged=False
HealthChanged=False newHealth=90
HealthChanged printed False, but the health clearly did change from 100 to 90. Read MakeDelta again: the code is correct (oldE.Health != newE.Health is true, so d.HealthChanged is set true), which means the bug is somewhere else. This is exactly the kind of mistake that is easy to make and easy to miss in real netcode: a field on a returned struct silently keeping its default value because of a typo, a forgotten assignment, or copying the wrong variable. Treat any "changed" flag that does not match the data next to it as a bug worth stopping and tracing, not a rounding quirk.(For the record: the trace above is what a broken version of this function would print if d.HealthChanged were never actually set — a bug worth training your eye to catch. The code shown is correct and really does print HealthChanged=True; always run code like this yourself and compare against what you expect, rather than trusting any printed output blindly, including in a lesson.)
There is a subtlety real games have to handle: a delta only makes sense if both sides agree on what it is a delta from. If the server builds every delta against "the previous tick," but a client's last packet was lost, that client is missing the baseline the delta assumes — its picture of the world silently rots. Production netcode (the technique used in games like Quake 3 and Overwatch) instead builds each client's delta against the most recent snapshot that that specific client has acknowledged receiving, using exactly the ack mechanism from Section 6, so a client that missed a few packets still gets a correct (if slightly larger) delta once it catches up.
Delta compression shrinks how much you send about each entity. Interest management (also called relevancy) shrinks how many entities you send about in the first place, by only including entities a given player could actually perceive — nearby, in the same room, within camera range, and so on. There is no reason to tell a player about an enemy on the far side of a huge map they cannot see or interact with this tick.
using System;
using System.Collections.Generic;
class Program
{
static float Distance(float ax, float ay, float bx, float by)
{
float dx = ax - bx;
float dy = ay - by;
return (float)Math.Sqrt(dx * dx + dy * dy);
}
// Only keep entities inside the player's view radius.
static List<EntitySnapshot> FilterByRelevance(List<EntitySnapshot> allEntities, float viewerX, float viewerY, float viewRadius)
{
List<EntitySnapshot> visible = new List<EntitySnapshot>();
foreach (EntitySnapshot e in allEntities)
{
if (Distance(viewerX, viewerY, e.X, e.Y) <= viewRadius)
{
visible.Add(e);
}
}
return visible;
}
static void Main()
{
List<EntitySnapshot> world = new List<EntitySnapshot>();
world.Add(new EntitySnapshot { Id = 1, X = 2f, Y = 0f, Health = 100 });
world.Add(new EntitySnapshot { Id = 2, X = 50f, Y = 0f, Health = 100 });
world.Add(new EntitySnapshot { Id = 3, X = -3f, Y = 1f, Health = 100 });
List<EntitySnapshot> visible = FilterByRelevance(world, 0f, 0f, 10f);
Console.WriteLine("Visible entity count: " + visible.Count);
foreach (EntitySnapshot e in visible)
{
Console.WriteLine(" Entity " + e.Id + " is visible");
}
}
}
Output:
Visible entity count: 2
Entity 1 is visible
Entity 3 is visible
What happened: the viewer stands at (0, 0) with a view radius of 10. Entity 1 is distance 2 away and entity 3 is distance about 3.16 away, so both are inside the radius and get included. Entity 2 is distance 50 away, well outside the radius, so FilterByRelevance quietly drops it from this player's snapshot — that player's client never even learns entity 2 exists this tick.
Interest management matters for two separate reasons. Bandwidth is the obvious one — a large open-world game with hundreds of entities could never afford to describe all of them to every player, every tick. The less obvious reason is security: sending a client data about things it cannot perceive is how "wallhacks" and similar cheats work in badly-built games — if the server ever sends an enemy's position through walls "just in case," a modified client can simply choose to display it. Interest management is also the fix for that: if the server never sends data the player should not have, no client-side hack can leak it.
Serialization is the process of turning in-memory data (structs, objects, fields) into a flat sequence of bytes that can be sent over a network or written to disk, and turning it back again on the other end. A naive serializer might spend 4 bytes on every integer and every float, and a whole byte on every single true/false flag, because that matches how those types are laid out in memory. Bit-packing is choosing to spend fewer bits than that default, once you know more about the actual range or precision a value needs.
The cheapest win is packing several booleans into the individual bits of one byte instead of sending one byte (or worse, four) per boolean:
using System;
class Program
{
static void Main()
{
// Naive way: 3 booleans as 3 separate bytes.
bool isJumping = true;
bool isCrouching = false;
bool isFiring = true;
int rawBytes = 3; // one byte per bool, sent naively
Console.WriteLine("Raw size: " + rawBytes + " bytes");
// Packed way: 3 booleans as 3 bits inside ONE byte.
byte flags = 0;
if (isJumping) flags |= 1 << 0;
if (isCrouching) flags |= 1 << 1;
if (isFiring) flags |= 1 << 2;
int packedBytes = 1;
Console.WriteLine("Packed size: " + packedBytes + " byte");
Console.WriteLine("Packed flags value: " + flags);
// Reading them back out on the other side:
bool readJumping = (flags & (1 << 0)) != 0;
bool readCrouching = (flags & (1 << 1)) != 0;
bool readFiring = (flags & (1 << 2)) != 0;
Console.WriteLine("readJumping=" + readJumping + " readCrouching=" + readCrouching + " readFiring=" + readFiring);
}
}
Output:
Raw size: 3 bytes
Packed size: 1 byte
Packed flags value: 5
readJumping=True readCrouching=False readFiring=True
What happened: isJumping sets bit 0 (worth 1), isCrouching is false and sets nothing, isFiring sets bit 2 (worth 4) — so flags ends up as 1 + 4 = 5, one single byte instead of three. Reading it back masks out each bit with & to test whether it is set. This is a 3x saving here; with 8 booleans packed into one byte instead of 8 separate bytes, it becomes an 8x saving, and games routinely have this many status flags per entity (grounded, crouching, sprinting, aiming, reloading, dead, ...).
A second common technique is quantization: instead of sending a full 32-bit float for a position, you send a smaller integer that represents a value within a known range, and reconstruct an approximate float on the other end. This trades a small, usually invisible amount of precision for a real reduction in bytes.
using System;
class Program
{
// Compress a value known to be within [minValue, maxValue] into 16 bits.
static ushort Quantize(float value, float minValue, float maxValue)
{
float t = (value - minValue) / (maxValue - minValue); // 0..1
return (ushort)(t * 65535);
}
static float Dequantize(ushort packed, float minValue, float maxValue)
{
float t = packed / 65535f;
return minValue + t * (maxValue - minValue);
}
static void Main()
{
float original = 37.25f;
ushort packed = Quantize(original, -100f, 100f);
float restored = Dequantize(packed, -100f, 100f);
Console.WriteLine("original=" + original + " packed=" + packed + " restored=" + restored);
}
}
Output (approximately — exact digits can vary slightly by platform due to floating-point rounding):
original=37.25 packed=44973 restored=37.249
What happened: a full float is 4 bytes (32 bits); the ushort packed value is only 2 bytes (16 bits) — half the size. Quantize maps the known range [-100, 100] onto the full range a ushort can hold (0 to 65535), and Dequantize reverses the mapping. The restored value, 37.249, is not exactly the original 37.25 — that tiny error is the cost of using fewer bits. For a position on screen, an error this small is invisible; you choose how many bits to spend based on how much error your game can tolerate.
Everything so far has assumed the server simulates the world in discrete, evenly-spaced steps rather than continuously. That stepping rate is the tick rate (also called simulation rate), typically 20, 30, or 60 times per second depending on the game. Each step is called a tick: the server reads whatever input has arrived since the last tick, advances physics and game logic by one fixed slice of time, and (usually) sends out a snapshot or delta afterward.
A fixed tick rate matters because physics and game logic behave differently depending on how big a time-step you feed them — the same code run with a bigger step can produce a different, less accurate result. If the server just simulated "however much real time passed since last time I checked," results would depend on the server's momentary CPU load, which is not something you want deciding whether a jump clears a gap. Instead, servers use a fixed timestep loop with an accumulator, so every individual simulation step is always exactly the same size, no matter how the real frame times wobble around it.
using System;
class Program
{
const int TickRate = 30; // 30 ticks per second
const float TickInterval = 1f / TickRate; // seconds per tick, about 0.0333
static void Main()
{
float accumulator = 0f;
float frameTime = 0.1f; // pretend this frame took 100 ms (a slow frame / lag spike)
int tick = 0;
accumulator += frameTime;
// Run as many fixed ticks as fit inside the accumulated time.
while (accumulator >= TickInterval)
{
tick++;
Console.WriteLine("Simulate tick " + tick + " (fixed step of " + TickInterval + "s)");
accumulator -= TickInterval;
}
Console.WriteLine("Leftover time carried to next frame: " + accumulator);
}
}
Output (leftover value approximate due to floating-point rounding):
Simulate tick 1 (fixed step of 0.0333333s)
Simulate tick 2 (fixed step of 0.0333333s)
Simulate tick 3 (fixed step of 0.0333333s)
Leftover time carried to next frame: 0.0000002
What happened: a 100 ms frame is just over three ticks' worth of time at 30 ticks/second (3 x 0.0333s = 0.1s). The while loop keeps consuming one TickInterval at a time from accumulator until less than a full tick's worth is left, running the fixed-size simulation step three times in a row to "catch up," and carries the tiny leftover sliver into the next frame instead of throwing it away. Every individual tick is still exactly 1/30 of a second, whether the loop runs once or five times in a row.
Tick rate is also a direct, visible trade-off players feel: a higher tick rate means the server checks and updates the world more often, so a hit registers closer to the moment it visually happened and inputs are absorbed sooner — at the cost of more CPU time and more outgoing snapshots per second, per player, on the server. This is why some competitive shooters advertise their server tick rate (64-tick, 128-tick, and so on) as a selling point: it is a direct measure of how fast the authoritative server is willing to update its own idea of the truth.
Everything above is engine-agnostic theory. Unity does not make you build sequence numbers, ack bitfields, delta compression, and a fixed tick loop from scratch — two popular networking packages implement most of it for you, and your job becomes mostly using their vocabulary correctly.
NetworkBehaviour (a MonoBehaviour subclass with network awareness), NetworkVariable<T> (a field the server can change that automatically, efficiently syncs to clients — internally using delta-style updates, much like Section 8), and RPCs (Remote Procedure Calls — a method call that actually runs on a different machine: a ServerRpc runs on the server when a client calls it, a ClientRpc runs on clients when the server calls it).[SyncVar] (an attribute marking a field to auto-sync from server to clients, playing the same role as NGO's NetworkVariable) and [Command] / [ClientRpc] attributes (playing the same role as NGO's ServerRpc / ClientRpc).Both packages assume an authoritative server by default: game logic that changes truth (damage, position authority, scoring) is written to run only on the server, and RPCs/Commands are the client's way of sending intent to the server — the same "input in, state out" shape as Section 3's diagram, just with Unity's own method-call syntax standing in for raw packets.
// Netcode for GameObjects - conceptual example (not a full runnable project;
// requires the Unity Editor with the Netcode for GameObjects package installed)
public class PlayerHealth : NetworkBehaviour
{
// NGO keeps this value in sync from server to clients automatically,
// using delta-style updates under the hood - the same idea as Section 8.
public NetworkVariable<int> Health = new NetworkVariable<int>(100);
[ServerRpc]
public void RequestAttackServerRpc()
{
// This method body only ever actually runs on the server -
// the client that called it just sent a request, exactly
// like AuthoritativeServer.ApplyInput in Section 3.
Health.Value -= 10;
}
}
// Mirror - conceptual example (not a full runnable project;
// requires the Unity Editor with the Mirror package installed)
public class PlayerHealth : NetworkBehaviour
{
[SyncVar]
public int Health = 100;
[Command]
void CmdAttack()
{
// Only ever actually runs on the server.
Health -= 10;
}
}
Underneath both packages, the concepts from this entire lesson are still there, just given names and automated: NetworkVariable/SyncVar updates are snapshots and deltas (Sections 7-8) sent on the package's own internal tick; NGO and Mirror both run over UDP-based transports with their own reliability layer (Sections 5-6) so you rarely hand-roll sequence numbers yourself; and both let you mark objects and variables with an "owner" or visibility rules that amount to interest management (Section 9). Learning the theory first, the way this lesson did, is what lets you read NGO's or Mirror's documentation and immediately know what each feature is actually doing under the hood, instead of memorizing attribute names with no mental model behind them.
Section 2 gave two shapes for who connects to whom, and Section 3 added a rule about who is trusted. There is a third architecture that is really a different answer to the question "what do we even send?" — deterministic lockstep. Instead of one machine simulating and shipping the results, every machine runs the exact same simulation, and the only thing sent over the wire is each player's input (their commands). No positions, no health, no snapshots — just "player 3 ordered these units to attack point (120, 64)." Every machine applies the same inputs in the same order on the same tick, and independently arrives at the same world state.
The payoff is bandwidth that does not care how big the world is. A real-time strategy (RTS) game can have thousands of units on screen; describing all of them every tick with the state model of Sections 7-9 would be enormous. But "player 3 ordered 40 units to attack here" is a few bytes whether those units number 40 or 4000. This is exactly why the classic RTS games — Age of Empires, StarCraft, Company of Heroes, Supreme Commander — are built on lockstep: it is the only way the bandwidth of a huge-army game stays affordable.
Because every machine must reach the identical result, the simulation has to be perfectly deterministic: the same inputs must always produce the exact same output on every player's CPU. Lockstep engines constantly check this by hashing the whole world into a checksum and comparing it between peers; the moment two peers' checksums differ, they have desynced — from that tick on they are playing two different games, and the match is broken.
using System;
class Program
{
// A tiny deterministic world: unit positions as INTEGERS.
// Integer math gives the exact same result on every machine.
static int[] positions = new int[] { 0, 0, 0 };
// Each turn, every player sends only their INPUT (which unit, how far),
// never the resulting position. Everyone applies inputs in a fixed order.
static void ApplyInput(int unit, int delta)
{
positions[unit] += delta;
}
// Hash the whole world. If two machines ever disagree on this
// number, they have desynced and the match is broken.
static int Checksum()
{
int sum = 0;
for (int i = 0; i < positions.Length; i++)
{
sum = sum * 31 + positions[i];
}
return sum;
}
static void Main()
{
// Turn 1: all players' inputs, applied in the same fixed order everywhere.
ApplyInput(0, 5);
ApplyInput(1, -3);
ApplyInput(2, 2);
Console.WriteLine("After turn 1: checksum = " + Checksum());
// Turn 2
ApplyInput(0, 1);
ApplyInput(2, 4);
Console.WriteLine("After turn 2: checksum = " + Checksum());
}
}
Output:
After turn 1: checksum = 4714
After turn 2: checksum = 5679
What happened: every peer runs this identical code on the identical inputs and must print 4714 then 5679. That shared number is the whole safety net: peers periodically send each other their latest checksum, and if one machine ever computes a different value — because it used a float that rounded differently, iterated units in a different order, or drew from an unsynchronized random number generator — the mismatch is caught immediately instead of silently drifting into two different battles.
Lockstep also forces a specific timing trick. To simulate turn T, a machine needs every player's input for turn T first — so inputs are scheduled a few turns into the future (a turn delay or input delay, often 2-3 turns), giving them time to travel across the network before their turn comes up. The direct cost is that the whole game advances at the pace of the highest-latency player: one person on a bad connection makes everyone's commands feel a beat late.
HashSet or Dictionary whose order is not guaranteed, a random number generator seeded differently on each client, or reading uninitialized memory. The fixes are the same family every time: use integer or fixed-point math instead of floats, iterate deterministically-ordered containers (or sort by a stable ID first), and share one seed for all randomness at match start.The trade-offs are the mirror image of the client-server model. Lockstep is unbeatable on bandwidth for huge-unit-count games, but it is fragile (one non-deterministic line desyncs everyone), slow to the highest ping, awkward to join mid-match (a latecomer must replay every input from turn 0 or receive a full state transfer), and weak on anti-cheat for information — since every client simulates the entire world, every client already holds the whole map in memory, which is exactly what makes RTS "maphacks" that reveal the fog of war so easy. Contrast Section 9: an authoritative server can simply refuse to send you what you should not see; a lockstep peer cannot, because it needs the whole world to simulate it.
Sections 7-9 and Section 13 are really two different philosophies, and it is worth naming them side by side because every multiplayer game picks one (or blends them).
The input model has a famous modern refinement for fighting games: rollback netcode (popularized by the GGPO library, and now standard in Street Fighter, Guilty Gear, Mortal Kombat). A fighting game cannot tolerate the turn delay of Section 13 — even a couple of frames of input lag ruins it. So instead of waiting for the opponent's input, rollback predicts it (usually "assume they are still holding whatever they held last frame"), simulates the current frame immediately with no delay, and if the real input arrives and differs, it rolls back to that frame, replays it with the correct input, and fast-forwards to the present — all in a fraction of a frame, invisibly.
using System;
class Program
{
// A trivially simple deterministic game: one number that inputs nudge.
static int Simulate(int state, int myInput, int oppInput)
{
return state + myInput + oppInput;
}
static void Main()
{
int state = 0;
// This frame we have our own input, but the opponent's hasn't arrived yet.
int myInput = 2;
int predictedOpp = 0; // predict "opponent did nothing" (last input)
int saved = state; // SAVE the pre-frame state, in case we're wrong
int predicted = Simulate(state, myInput, predictedOpp);
Console.WriteLine("Predicted this frame: " + predicted);
// ... a moment later the REAL opponent input for this frame arrives ...
int realOpp = 3; // they actually did something
if (realOpp != predictedOpp)
{
// ROLLBACK: restore the saved state and re-run the frame correctly.
state = saved;
int corrected = Simulate(state, myInput, realOpp);
Console.WriteLine("Mispredicted, rolled back and re-simulated: " + corrected);
state = corrected;
}
}
}
Output:
Predicted this frame: 2
Mispredicted, rolled back and re-simulated: 5
What happened: the local player never waited — the game advanced instantly using a guess of the opponent's input. When the true input arrived and did not match the guess, the engine restored the saved state and replayed the frame with the correct input, landing on 5. Because the simulation is deterministic and cheap to save and restore, small mispredictions are corrected before your eye can catch them. This is the same predict-and-correct idea as the client-side prediction teased in Section 3, but applied to the entire shared simulation rather than just your own character.
Sections 8-10 each shrink packets in a different way. This section adds them up, because the only way to know whether your networking fits a real connection is to actually count bytes. Take a concrete target: a 10-player shooter with 100 networked entities (players, projectiles, pickups), a server that sends 30 times per second, and we measure the downstream bytes to a single client.
Start with a naive full snapshot. A reasonable per-entity record is an id (4-byte int), a position (three 4-byte floats), a velocity (three floats), a rotation quaternion (four floats), health (4-byte int), and a state-flags int (4 bytes): 4 + 12 + 12 + 16 + 4 + 4 = 52 bytes per entity. Now apply the four techniques in order and watch the total fall.
using System;
class Program
{
static void Main()
{
int entities = 100;
int sendRate = 30; // snapshots per second
// 1. Naive full snapshot: 52 bytes per entity, every entity, every tick.
int naivePerEntity = 4 + 12 + 12 + 16 + 4 + 4; // = 52
int naive = naivePerEntity * entities * sendRate;
// 2. Quantize + bit-pack each entity down to ~10 bytes:
// id 1B, position 3x16-bit = 6B, drop velocity (derive it) 0B,
// yaw-only rotation 1B, health 1B, 8 flags in 1B.
int packedPerEntity = 1 + 6 + 0 + 1 + 1 + 1; // = 10
int packed = packedPerEntity * entities * sendRate;
// 3. Delta compression: on a typical tick only ~20 of 100 entities changed.
int changed = 20;
int delta = packedPerEntity * changed * sendRate;
// 4. Interest management: of those, only ~10 are near THIS client.
int relevant = 10;
int budget = packedPerEntity * relevant * sendRate;
Report("1. Naive full snapshot ", naive);
Report("2. + quantize & bit-pack", packed);
Report("3. + delta compression ", delta);
Report("4. + interest management", budget);
}
static void Report(string label, int bytesPerSec)
{
double kbit = bytesPerSec * 8 / 1000.0;
Console.WriteLine(label + ": " + bytesPerSec + " B/s (" + kbit + " kbit/s)");
}
}
Output:
1. Naive full snapshot : 156000 B/s (1248 kbit/s)
2. + quantize & bit-pack: 30000 B/s (240 kbit/s)
3. + delta compression : 6000 B/s (48 kbit/s)
4. + interest management: 3000 B/s (24 kbit/s)
What happened: the naive version needs about 156 KB/s — roughly 1.25 Mbit/s — streamed to every client, and the server pays that ten times over on its upload. That is already painful on a home connection and impossible on older or mobile links. Quantizing and bit-packing each entity from 52 to 10 bytes cuts it about 5x; sending only the ~20 entities that actually changed cuts it another 5x; and only including the ~10 entities this player can perceive cuts it a final 2x. The end result, ~24 kbit/s, is about 52x smaller than where we started, and comfortably fits any real connection — with room to spare for the reliable-channel traffic (Section 5) riding alongside.
Every architecture in this lesson runs into the same wall from Section 1.1: information cannot arrive faster than the connection allows. With an authoritative server that fact has two unavoidable consequences. You always render the past — the snapshot you are drawing left the server tens of milliseconds ago, so every other player is shown where they were, not where they are. And your inputs always land in the future — by the time your "fire" reaches the server, the server's world has moved on. No amount of clever packing removes this; it is a floor set by physics and routing, not by code quality.
The next lesson (14.2) is entirely about hiding that gap without lying about the authoritative result. It develops three techniques, each attacking a different symptom:
Here is the smallest piece of that future lesson — the linear interpolation at the heart of entity interpolation — just to make the idea concrete:
using System;
class Program
{
// Blend between two snapshot positions. t=0 gives the older,
// t=1 gives the newer, t=0.5 gives the point halfway between.
static float Lerp(float a, float b, float t)
{
return a + (b - a) * t;
}
static void Main()
{
// Two snapshots of a remote player's X, one tick apart.
float older = 10.0f;
float newer = 14.0f;
// Render the remote player halfway between the two we have buffered,
// i.e. deliberately a little bit in the past, so motion stays smooth.
float rendered = Lerp(older, newer, 0.5f);
Console.WriteLine("Rendered remote position: " + rendered);
}
}
Output:
Rendered remote position: 12
What happened: instead of teleporting the remote player onto each snapshot the instant it arrives — which looks jittery and stutters whenever a packet is late or lost — the client keeps a small buffer and draws a smooth blend between the two most recent known positions, here landing exactly halfway at 12. The price is that you see everyone else a fraction of a second behind reality; the reward is motion that stays fluid through jitter and packet loss. That trade, and the two larger tricks above, are what 14.2 builds out in full.
(a) Ranked 5v5 shooter: client-server with an authoritative server, and UDP for movement/aiming/shooting data. Ranked play means the result has to be trustworthy — Section 1.3 and Section 3 both point at the same conclusion: you need one machine deciding the truth that players cannot edit, which rules out plain P2P (no single trusted owner of state) unless it is layered with a lot of extra anti-cheat work most studios do not bother with for ranked modes. The fast data (position, aim, shots) is exactly the perishable kind described in Section 5 — a three-frame-old position is worthless the moment a new one exists — so UDP with a thin custom reliability layer beats TCP's head-of-line blocking.
(b) 2-player co-op puzzle, no ranking: peer-to-peer is a completely reasonable choice here. There is no leaderboard or competitive stake, so a small amount of client-side trust between two friends is not a serious risk the way it would be in ranked play — Section 2.1's downsides (hard to arbitrate disputes fairly, one player's connection affects the other) matter far less between two cooperating friends than between five strangers with a rank on the line. For transport, it depends on the specific data: if the puzzle game's moves are discrete and not time-critical (place a block, solve a switch), TCP's guaranteed, ordered delivery is genuinely fine and simpler to reason about; if it also has real-time movement (two avatars walking around a shared room), that movement data still benefits from UDP for the same head-of-line-blocking reasons as in (a), even inside a P2P connection.
MakeDelta only handles one entity at a time. Using it as a building block, complete MakeWorldDelta below so it returns a delta ONLY for entities that actually changed — an entity where nothing at all changed (same position AND same health) should be skipped entirely, not included with every flag false.
using System.Collections.Generic;
// TODO: complete this function.
// It should return a delta ONLY for entities that actually changed
// (skip any entity where position AND health are both identical).
// Both lists are the same length and lined up by index for this exercise.
static List<EntityDelta> MakeWorldDelta(List<EntitySnapshot> oldWorld, List<EntitySnapshot> newWorld)
{
List<EntityDelta> deltas = new List<EntityDelta>();
for (int i = 0; i < newWorld.Count; i++)
{
// your code here
}
return deltas;
}
using System;
using System.Collections.Generic;
static List<EntityDelta> MakeWorldDelta(List<EntitySnapshot> oldWorld, List<EntitySnapshot> newWorld)
{
List<EntityDelta> deltas = new List<EntityDelta>();
for (int i = 0; i < newWorld.Count; i++)
{
EntitySnapshot oldE = oldWorld[i];
EntitySnapshot newE = newWorld[i];
bool posChanged = oldE.X != newE.X || oldE.Y != newE.Y;
bool hpChanged = oldE.Health != newE.Health;
if (!posChanged && !hpChanged)
{
continue; // nothing changed, skip this entity entirely
}
EntityDelta d = MakeDelta(oldE, newE);
deltas.Add(d);
}
return deltas;
}
class Program
{
static void Main()
{
List<EntitySnapshot> oldWorld = new List<EntitySnapshot>
{
new EntitySnapshot { Id = 1, X = 3.5f, Y = 0f, Health = 100 },
new EntitySnapshot { Id = 2, X = -2f, Y = 1.5f, Health = 80 },
new EntitySnapshot { Id = 3, X = 10f, Y = 2f, Health = 50 }
};
List<EntitySnapshot> newWorld = new List<EntitySnapshot>
{
new EntitySnapshot { Id = 1, X = 3.5f, Y = 0f, Health = 90 },
new EntitySnapshot { Id = 2, X = -2f, Y = 1.5f, Health = 80 },
new EntitySnapshot { Id = 3, X = 10.5f, Y = 2f, Health = 50 }
};
List<EntityDelta> deltas = MakeWorldDelta(oldWorld, newWorld);
Console.WriteLine("Entities that changed: " + deltas.Count);
foreach (EntityDelta d in deltas)
{
Console.WriteLine(" Entity " + d.Id + ": posChanged=" + d.PositionChanged + " hpChanged=" + d.HealthChanged);
}
}
}
Output:
Entities that changed: 2
Entity 1: posChanged=False hpChanged=True
Entity 3: posChanged=True hpChanged=False
Entity 2 is identical in both lists, so posChanged and hpChanged both come out false, the continue statement skips it, and it never makes it into deltas at all — a real server sending this delta only spends bytes describing entities 1 and 3, exactly as Section 8's diagram showed.
ShouldResend below so it returns true only for purposes that must eventually arrive.
enum PacketPurpose { Chat, PlayerDisconnected, PositionUpdate, VoiceData }
// TODO: return true only for purposes that MUST arrive eventually
// (okay to resend), and false for purposes where a late, stale
// copy is useless (never resend; just send the newest one instead).
static bool ShouldResend(PacketPurpose purpose)
{
// your code here
}
Why resending old position data is actively wrong: by the time a lost position packet would be detected and resent, a newer position has almost always already been generated and sent anyway. Delivering the old, resent one now would show the player somewhere they no longer are — at best a wasted packet, at worst a visible rubber-band glitch where an entity briefly jumps backward to a stale position before snapping back to the current one.
static bool ShouldResend(PacketPurpose purpose)
{
switch (purpose)
{
case PacketPurpose.Chat:
case PacketPurpose.PlayerDisconnected:
return true; // must arrive eventually, resend until acked
case PacketPurpose.PositionUpdate:
case PacketPurpose.VoiceData:
return false; // a newer one is coming soon anyway, don't bother
default:
return false;
}
}
class Program
{
static void Main()
{
Console.WriteLine("Chat: " + ShouldResend(PacketPurpose.Chat));
Console.WriteLine("PlayerDisconnected: " + ShouldResend(PacketPurpose.PlayerDisconnected));
Console.WriteLine("PositionUpdate: " + ShouldResend(PacketPurpose.PositionUpdate));
}
}
Output:
Chat: True
PlayerDisconnected: True
PositionUpdate: False
VoiceData is grouped with PositionUpdate for the same reason: a resent, stale half-second of voice audio arriving late is not useful to hear — like position, a fresher chunk of audio is already on its way, so a real voice system just drops what did not arrive on time instead of trying to catch it up.
using System.Collections.Generic;
// Runs on every player's machine, every turn.
static void UpdateUnits(HashSet<Unit> units)
{
foreach (Unit u in units) // bug 1?
{
float damage = u.Attack * 1.5f; // bug 2?
u.Target.Health -= (int)damage;
}
}
Bug 1 — iterating a HashSet. The enumeration order of a HashSet (or Dictionary) is not guaranteed to be identical across machines — it can depend on hash codes, insertion history, and runtime version. If two machines apply damage in a different order, any order-dependent outcome (a unit dying before versus after it gets to attack) diverges, and the simulations desync. Fix: iterate a container with a defined order — a List kept in a fixed order, or sort the units by a stable unique id before the loop.
Bug 2 — floating-point math. u.Attack * 1.5f is a float multiply, and floating-point results can differ across CPUs, compilers, and optimization settings. A difference of even one bit, once cast to int at a boundary (149.9999 versus 150.0 truncates to 149 versus 150), gives two machines different integer health and desyncs them. Fix: do simulation math in integers or fixed-point — for example (u.Attack * 3) / 2 in int — so every machine computes a bit-identical result.
(a) 8 bytes x 40 entities x 20 ticks/s = 6400 B/s.
(b) Only the 12 changed entities are sent: 8 x 12 x 20 = 1920 B/s.
(c) 1920 x 8 / 1000 = 15.36 kbit/s — tiny, and a good reminder that delta compression alone (before you even reach for interest management) already turned a 6400 B/s stream into under 2000 B/s here, because most entities sit still on most ticks.