14.1 Multiplayer Architecture

Phase 14 · Networking & Live-Service · Study time: 30–50 h

Authoritative servers, UDP vs TCP, building reliability over UDP, and snapshot / delta replication of game state.

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.

1. Why Networking Is Hard

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.

1.1 The network is slow (latency)

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.

CLIENT SERVER t=0ms player presses "jump" send input packet ----------> t=50ms receive input, simulate the jump (player already pressed 2-3 more keys by the time anything comes back) t=100ms receive result packet <---------- t=50ms send result back A 100ms round trip means the player is always reacting to information that is already out of date.

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.

1.2 The network is unreliable (packet loss and jitter)

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.

1.3 Players can lie (cheating)

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.

Common mistake Sending a player's own position, health, or score as a plain value the server just writes down. If the client can say it, the client can lie about it. The fix is not "encrypt the message" (a modified client can still send a valid, encrypted lie) — the fix is architectural: the server must compute these values itself, as you'll see in Section 3.

2. Two Architectures: Peer-to-Peer vs Client-Server

There are two basic shapes for who talks to whom in a multiplayer game.

2.1 Peer-to-peer (P2P)

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.

Player A <-----> Player B ^ ^ | | v v Player C <-----> Player D Every arrow is a direct connection between two players. (A also connects directly to D, and B to C - not drawn, to keep the picture simple.) The number of connections grows fast: 4 players need 6 links, 8 players need 28.

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

2.2 Client-server

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.

Player A Player B Player C | | | +------+------+------+-----+ | v +----------------+ | SERVER | | (owns the true | | state) | +----------------+ | +------------+------------+ v v v Player A Player B Player C (gets new (gets new (gets new state) state) state)

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.

Tip "Client-server" is about who talks to whom. It says nothing yet about who is trusted to decide facts. That is a separate idea, covered next.

3. The Authoritative Server: The Server Owns the Truth

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.

CLIENT SERVER ------ ------ read input (move, attack) | v send INPUT packet --------------> receive input | v simulate physics, apply game rules, compute new true state | receive STATE packet <-------------- send STATE snapshot | v show the world on screen (correct local prediction if it guessed wrong)

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.

Tip A simple test for whether your architecture is actually authoritative: could a player win a match by sending fabricated bytes instead of playing the game? If the answer is yes, something in your protocol lets the client state a fact instead of a request.

4. TCP vs UDP: Two Very Different Ways to Send Bytes

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.

4.1 TCP: reliable, ordered, and it will make you wait

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.

Packets sent in order: [1] [2] [3] [4] [5] Packet 2 gets lost on the way. TCP delivery to the app (strictly in order, no gaps allowed): [1] delivered [2] LOST -- TCP must wait and resend it [3] arrived but HELD, waiting behind [2] [4] arrived but HELD, waiting behind [2] [5] arrived but HELD, waiting behind [2] ... resend of [2] finally arrives ... [2] [3] [4] [5] delivered all at once, late Even though 3, 4, and 5 already fully arrived, the app could not use them yet. That stall is "head-of-line blocking".

4.2 UDP: fast, unordered, and nothing is guaranteed

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.

Common mistake Choosing TCP for a fast-paced action game "because it's reliable and simpler." Reliability is exactly the feature that causes head-of-line blocking. A three-year-old position update stuck behind a lost packet is worse than useless — by the time it is finally delivered, a newer position already exists. TCP's guarantee is the wrong guarantee for data that goes stale within a fraction of a second.

5. Why Fast Games Use UDP (and Build Their Own Reliability)

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").

SENDER RECEIVER seq=10 ------------------------------> got seq=10 seq=11 ------------------------------> (lost in transit) seq=12 ------------------------------> got seq=12 sends back: ack=12, ackBits show "10 got, 11 missing" got ack=12, ackBits... <------------------ ack=12, ackBits=... Sender sees that seq 11 was never acked. It resends ONLY the important part of packet 11's data -- not 10 or 12, since those are already confirmed received.

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.

Tip This is why "UDP" and "unreliable" are not quite synonyms in real game code. UDP is the transport; on top of it, most games build both an unreliable channel (perishable data like position, sent every tick, never resent) and a reliable channel (important one-time events, resent until acked) — using the exact same sequence+ack machinery underneath for both.

6. Code: A Packet Header With Sequence Numbers and Acks

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.

Common mistake Forgetting that 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.

7. What Goes Over the Wire: Snapshots of State

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.

Server tick 100: world state --> full snapshot --> send to all clients Server tick 101: world state --> full snapshot --> send to all clients Server tick 102: world state --> full snapshot --> send to all clients Each snapshot fully describes every entity, every single tick. Simple to reason about, but wasteful if very little changed.

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.

8. Delta Compression: Send Only What Changed

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
Common mistake Look closely at that output — 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.)

FULL SNAPSHOT (tick 102) DELTA (tick 101 -> 102) ------------------------- ------------------------- entity 1: x=3.5 y=0.0 hp=100 entity 1: hp=90 (only hp changed) entity 2: x=-2.0 y=1.5 hp=80 entity 2: (nothing changed, omit) entity 3: x=10.0 y=2.0 hp=50 entity 3: x=10.5 (only x changed) Full snapshot: every entity, every field, every tick. Delta: only the fields that actually changed since last time.

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.

9. Interest Management: Only Send What a Player Can See

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.

World has 3 entities. Player A's view radius = 10 units. +-------------------------------------------------+ | | | Player/Entity 2 (dist 50 - NOT sent) | | | | +---------------------------+ | | | view radius = 10 | | | | | | | | Player A (viewer) | | | | Entity 3 (dist ~3.16) | | | | Entity 1 (dist 2) | | | | | | | +---------------------------+ | | | +-------------------------------------------------+ Only entities 1 and 3 are sent to Player A this tick. Entity 2 exists in the world but is irrelevant to A right now.

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.

Tip This is the same reasoning behind fog of war in strategy games and "you cannot see through walls" in shooters — it is not only a rendering rule, it is also (and arguably more importantly) a networking rule about what the server is willing to tell a given client at all.

10. Serialization and Bit-Packing: Making Packets Tiny

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.

Tip Quantization needs a range you can trust. If a player's X position could ever legitimately go outside the [-100, 100] you assumed, the packed value silently clips or wraps to garbage. Always base the range on the actual size of your level or the true limits of a value, not a guess.

11. Tick Rate and the Server Simulation Loop

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.

Real time keeps flowing smoothly ------------------------> Server fixed ticks (always the same size, no matter what): |--tick--|--tick--|--tick--|--tick--|--tick--| 1/30s 1/30s 1/30s 1/30s 1/30s A slow frame just means MORE ticks run back-to-back to catch up. Each individual tick is still the exact same fixed size - this keeps the simulation predictable.
Common mistake Simulating physics with whatever variable amount of time actually elapsed since the last update (a variable timestep), instead of a fixed one. The same input, replayed through variable-size steps, can produce a different outcome depending on unrelated CPU load — which is fatal for an authoritative server, where every tick's result has to be reproducible and fair. Fixed tick rate, catching up with multiple steps when needed, is what keeps simulation deterministic (the same inputs always produce the same output).

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.

12. Mapping This to Unity: Netcode for GameObjects and Mirror

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.

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.

Tip A studio interview about multiplayer rarely asks "which attribute syncs a variable in Mirror." It asks things much closer to this lesson's sections: why is the server authoritative, why UDP over TCP for movement, what is a delta, why does tick rate matter. The package API is the easy part to look up later; the architecture is what you are actually being hired to understand.

13. Deterministic Lockstep: A Third Architecture

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.

Each machine runs the SAME simulation. Only inputs are exchanged. Player A input --+ +-- Player C input v v +-------------------------------------------------+ | every peer: collect ALL inputs for turn T, | | step the identical simulation, | | reach the identical world state | +-------------------------------------------------+ ^ ^ Player B input --+ +-- Player D input Nobody ever sends a unit's position. 2000 units cost the same bandwidth as 5, because only commands travel.

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.

Common mistake Assuming a simulation is deterministic because it "looks the same when you run it twice on your own machine." Lockstep desyncs almost always come from differences between machines: floating-point results that vary by CPU or compiler flags, iterating a 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.

14. State Model vs Input Model: What Do You Actually Send?

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

STATE / SNAPSHOT MODEL INPUT / COMMAND MODEL -------------------- --------------------- server simulates every machine simulates sends RESULTS (positions, hp) sends INPUTS (commands) clients render what they're told clients re-run the same sim loss-tolerant (self-correcting) loss stalls the sim no determinism required determinism is mandatory bandwidth grows with WORLD size bandwidth grows with PLAYER count used by: shooters, MMOs used by: RTS lockstep, fighting games

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.

Tip Notice rollback and lockstep are both the input model, but make opposite choices about delay: lockstep waits for real inputs (adding turn delay) to stay perfectly in sync, while rollback guesses inputs (adding correction work) to stay perfectly responsive. Which cost you can afford is a genuine design decision — an RTS happily accepts a little input delay; a fighting game absolutely cannot.

15. Bandwidth Budgeting: Doing the Byte Math

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.

Common mistake Building one giant snapshot packet and handing it to UDP. A single UDP datagram larger than the network path's MTU (commonly about 1500 bytes, and safely around 1200 bytes of payload once headers are counted) gets fragmented by IP into multiple pieces — and if any fragment is lost, the whole datagram is discarded, turning a small loss into a large one. Real netcode keeps each packet under roughly 1200 bytes and splits a big snapshot across several packets (or trims it with interest management) rather than letting IP fragment it blindly.

16. The Latency Problem, and What 14.2 Solves

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.

17. Glossary

18. Exercises

Exercise 1 — Pick the Architecture For each of these two games, say whether you would use peer-to-peer or client-server, and whether the fast-changing gameplay data should travel over TCP or UDP. Justify each answer using ideas from Sections 2 and 4. (a) A ranked 5-versus-5 competitive shooter with a leaderboard. (b) A 2-player local co-op puzzle game played over the internet between two friends, with no ranking or leaderboard at all.
Show answer

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

Exercise 2 — Build a Whole-World Delta Section 8's 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;
}
Show answer

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.

Exercise 3 — Not Everything Deserves a Resend Section 5 explained that perishable data (like a position update) should not be resent when lost, while important one-time events (like a chat message or "player disconnected") should be. First, in one or two sentences, explain in your own words why resending a lost, old position update is often actively wrong, not just wasteful. Then complete 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
}
Show answer

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.

Exercise 4 — Spot the Desync A lockstep RTS (Section 13) runs perfectly in single-player tests but desyncs within seconds whenever two players connect over the network. Here is the per-turn update that every machine runs. Name the two independent determinism bugs, and say how you would fix each.

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;
    }
}
Show answer

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.

Exercise 5 — Budget the Bandwidth Using the method from Section 15, a co-op game has 40 networked entities, sends 20 times per second, and each entity packs down to 8 bytes. (a) What is the downstream bytes-per-second for a full packed snapshot every tick? (b) If delta compression means only 12 entities change on an average tick, what is the new bytes-per-second? (c) Express your answer to (b) in kbit/s.
Show answer

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

← Back to all chapters