Skip to main content

Optional Reading: State Sync & Bandwidth

  • Module 10: Real-time Multiplayer
  • Optional: not counted in course hours

Snapshots in the last two lessons carried everything, every tick. That is fine for a handful of objects on your own computer and far too much for a busy world on a real connection. This optional reading shows four ways to send less, and one way to make the few messages that must arrive, arrive.

🎯 Learning Objectives

By the end of this reading, you will be able to:

  • Build delta snapshots against the last state a client acknowledged, using real copies as baselines.
  • Quantize positions into a fixed number of bits, and calculate the bits a range and precision need.
  • Filter updates by distance with a spatial grid plus an exact distance check.
  • Explain a resend-until-acknowledged layer over UDP, including why each resend must restart its timer.

Project: a Delta Sync Budget program that measures full, delta and packed snapshots for 20 moving entities.

In This Reading

🧮 Send Only What Changed

A conductor doesn't hand the orchestra the whole score every bar; each player already has it and only needs to know what changes. A delta works the same way: compare the world now with a baseline the client already has, and send only the fields that differ, plus the entities that were removed.

graph LR W["World this tick"] --> D{"Compare with<br/>client's baseline"} B["Baseline: last state<br/>the client acknowledged"] --> D D --> Q["Changed fields only"] Q --> P["Quantize and pack"] P --> N["Send"] N -->|"ack"| B
def snapshot(world):
    """A COPY of the world: later changes to the world must not reach old snapshots."""
    return {eid: dict(fields) for eid, fields in world.items()}


def make_delta(baseline, current):
    """What changed from baseline to current: changed fields, new entities, removals."""
    changed = {}
    for eid, fields in current.items():
        old = baseline.get(eid, {})
        diff = {k: v for k, v in fields.items() if old.get(k) != v}
        if diff:
            changed[eid] = diff
    removed = [eid for eid in baseline if eid not in current]
    return {"changed": changed, "removed": removed}


def apply_delta(baseline, delta):
    world = snapshot(baseline)
    for eid in delta["removed"]:
        world.pop(eid, None)
    for eid, diff in delta["changed"].items():
        world.setdefault(eid, {}).update(diff)
    return world

Two rules decide whether deltas work:

  • The baseline must be a copy. If the server stores dict(world) or the entity objects themselves, the "baseline" changes whenever the world does, every comparison finds nothing different, and after the first tick no deltas are sent at all. snapshot() copies each entity's dictionary too.
  • The baseline must be what the client has. Over UDP a delta can be lost. If the server computes the next delta against something the client never received, the client's world goes wrong and stays wrong. So the client acknowledges snapshots by tick, and the server keeps a baseline per client: the newest snapshot that client acknowledged. When no acknowledgment has arrived yet, the baseline is empty and the "delta" is the full state.

🔢 Quantization: Fewer Bits per Number

A position like 1873.2849731445312 costs 18 characters in JSON, but nobody can see a difference smaller than a fraction of a pixel. Quantization maps a known range onto a fixed number of integer steps. With 16 bits, a world 4096 px wide gets 65,536 steps of about 0.06 px each, stored in 2 bytes.

def quantize(value, lo=0.0, hi=WORLD_SIZE, bits=POS_BITS):
    """Map a float in lo..hi to an integer 0..2**bits - 1 (clamped, so it always fits)."""
    top = 2 ** bits - 1
    q = round((value - lo) / (hi - lo) * top)
    return max(0, min(top, q))


def dequantize(q, lo=0.0, hi=WORLD_SIZE, bits=POS_BITS):
    return lo + q / (2 ** bits - 1) * (hi - lo)


def pack_position(x, y):
    return struct.pack("!HH", quantize(x), quantize(y))   # H = unsigned 16-bit: 0..65535

The scheme must fit the range. A common shortcut, "multiply by 100 and pack as a signed short ('!h')", only holds values up to 32,767, so any coordinate of 327.68 px or more makes struct.pack raise struct.error. Start from the range and the precision you need instead:

bits = math.ceil(math.log2(WORLD_SIZE / precision + 1))   # 4096 px at 0.1 px: 16 bits

Round-trip error is at most half a step, and the clamp means an out-of-range value is squeezed to the edge instead of crashing. Angles, health and velocities quantize the same way, each with its own range.

👁️ Interest Management

A player on one side of a big map doesn't need 60 updates a second about a crate on the other side. Interest management sends each client only the entities near it. A spatial grid (the spatial hash from the Spatial Hashing & Object Pools lesson) finds the candidates quickly, and an exact distance check keeps only the ones inside the view radius, because the grid's square of cells reaches further than the circle.

import random
from collections import defaultdict

CELL = 256                    # grid cell size in px: at least the view radius works well
VIEW_RADIUS = 300


def build_grid(entities):
    """Spatial hash: (cell_x, cell_y) -> list of entity ids in that cell."""
    grid = defaultdict(list)
    for eid, (x, y) in entities.items():
        grid[(int(x // CELL), int(y // CELL))].append(eid)
    return grid


def visible_to(observer, entities, grid, radius=VIEW_RADIUS):
    """Ids within radius of the observer: a cheap grid lookup, then an exact distance check."""
    ox, oy = entities[observer]
    reach = int(radius // CELL) + 1
    cx, cy = int(ox // CELL), int(oy // CELL)
    seen = []
    for gx in range(cx - reach, cx + reach + 1):
        for gy in range(cy - reach, cy + reach + 1):
            for eid in grid.get((gx, gy), []):
                if eid == observer:
                    continue
                x, y = entities[eid]
                if (x - ox) ** 2 + (y - oy) ** 2 <= radius * radius:   # the check that matters
                    seen.append(eid)
    return sorted(seen)


rng = random.Random(4)
entities = {i: (rng.uniform(0, 4096), rng.uniform(0, 4096)) for i in range(200)}
grid = build_grid(entities)
near = visible_to(0, entities, grid)
brute = sorted(e for e in entities if e != 0 and
               (entities[e][0] - entities[0][0]) ** 2 + (entities[e][1] - entities[0][1]) ** 2
               <= VIEW_RADIUS ** 2)
print(f"player 0 needs updates for {len(near)} of {len(entities) - 1} other entities")
print("grid answer matches a brute-force check:", near == brute)

Entities entering or leaving a client's area appear in that client's delta as new or removed, so the delta machinery above handles them without extra code. Games also hide information this way: a client that never receives an enemy's position can't draw it through a wall.

📊 Priorities and a Byte Budget

Even after deltas and filtering, a busy moment can produce more updates than a connection should carry. A simple budget works well: give each pending update a priority (closer entities and things the player is aiming at score higher), sort, and send in that order until the tick's byte budget is spent. Anything left over waits for the next tick with its priority raised by how long it has waited, so distant entities still update, only less often. Because the delta is computed against the acknowledged baseline, a skipped entity isn't lost: its change is simply still in the next delta.

✅ Growth Mindset: Optimize What You Measured

Bandwidth tricks are fun, and it is tempting to try all of them at once. Resist that, and don't feel behind if your first networked game sends plain JSON: that's the right place to start. Measure bytes per tick first, as the exercise does, then apply one technique and measure again. You'll learn which ideas matter for your game, and you'll never spend a weekend compressing something that was already small.

📬 A Little Reliability over UDP

Most updates can be lost harmlessly, because a newer one follows. A few can't: "item picked up", "player joined", "round over". Over UDP you add just enough reliability for those: number each important message, keep it until the other side acknowledges it, and send it again if no acknowledgment arrives in time. The receiver remembers which numbers it has seen, so a message that arrives twice is handled once.

import random

RESEND_AFTER = 0.2            # seconds without an ack before sending again
MAX_ATTEMPTS = 5


class ReliableSender:
    """Re-send important messages over UDP until they are acknowledged."""

    def __init__(self):
        self.next_seq = 1
        self.pending = {}                      # seq -> [message, resend_at, attempts]

    def send(self, now, message):
        seq = self.next_seq
        self.next_seq += 1
        self.pending[seq] = [message, now + RESEND_AFTER, 1]
        return seq, message

    def on_ack(self, seq):
        self.pending.pop(seq, None)

    def due(self, now):
        """Messages to send again now. Each one's timer restarts, so it waits again."""
        out = []
        for seq, entry in list(self.pending.items()):
            message, resend_at, attempts = entry
            if now < resend_at:
                continue
            if attempts >= MAX_ATTEMPTS:
                del self.pending[seq]           # give up: the connection is probably gone
                print(f"  gave up on #{seq}")
                continue
            entry[1] = now + RESEND_AFTER      # refresh the timer, or it resends every tick
            entry[2] = attempts + 1
            out.append((seq, message))
        return out


class ReliableReceiver:
    def __init__(self):
        self.seen = set()

    def receive(self, seq, message):
        """Returns the message the first time a seq arrives, None for duplicates."""
        if seq in self.seen:
            return None
        self.seen.add(seq)
        return message


rng = random.Random(2)
LOSS = 0.3                                     # lose 30% of datagrams, each way
sender, receiver = ReliableSender(), ReliableReceiver()
delivered, datagrams = [], 0
wire = [sender.send(0.0, f"item {n} picked up") for n in range(1, 6)]
now = 0.0
while now < 3.0 and (wire or sender.pending):
    for seq, message in wire:
        datagrams += 1
        if rng.random() < LOSS:
            continue                           # lost on the way there
        got = receiver.receive(seq, message)
        if got is not None:
            delivered.append(got)
        if rng.random() >= LOSS:               # the ack can be lost too
            sender.on_ack(seq)
    now += 0.05                                # one 20 Hz network tick later
    wire = sender.due(now)

print(f"delivered {len(delivered)} of 5, each once: {len(delivered) == len(set(delivered))}")
print(f"datagrams sent: {datagrams}")

Two details prevent the classic failures:

  • Refresh the timer on every resend. If due() compared against the time of the first send, every message older than the timeout would be sent again on every tick: a flood of duplicates that makes a congested connection worse.
  • Give up eventually. After a few attempts the connection is probably gone. Stop retrying and let the game's timeout or disconnect logic take over.

This is also, in miniature, what TCP does for every byte. The difference is that you choose which messages deserve it.

🏋️ Practice Exercise: Delta Sync Budget

Objective: finish a program that sends 20 moving entities to a client for 60 ticks as full snapshots, deltas and packed deltas, rebuilds the world on the client, and prints how many bytes each approach used.

Time: about 25 minutes. Starter file: delta_sync_starter.py (your instructor has it). Its numbered comments match the steps below. No pygame needed.

  1. Run the starter. The delta line looks amazingly small, and the last lines say the client world does not match. (≈ 3 min)
  2. Fix snapshot() so it copies every entity's dictionary (comment 1). Run it again: the delta is bigger, and now correct. (≈ 7 min)
  3. Write quantize(): scale to 0..top, round, clamp (comment 2). (≈ 8 min)
  4. Change WORLD_SIZE to 8192 without changing POS_BITS. What happens to the step size and the error? (≈ 5 min)

You are done when:

  • the program prints Client world matches the server: True;
  • the quantization error is within half a step;
  • you can explain why the starter's tiny delta was a bug, not a win.
💡 Hint

dict(world) copies only the outer dictionary; the entity dictionaries inside are still shared. A dictionary comprehension that calls dict(fields) for each entity copies both levels.

✅ Example Solution
"""Delta Sync Budget: optional reading "State Sync & Bandwidth" exercise (solution).

A server sends 20 moving entities to one client for 60 ticks, three ways:
full JSON snapshots, JSON deltas against the last snapshot the client
acknowledged, and "packed" deltas: binary, with positions quantized to 16 bits.
The client rebuilds the world from the deltas; the program checks that the
rebuilt world matches the server's (to within the quantization step).
"""
import json
import math
import random
import struct

WORLD_SIZE = 4096.0               # positions run from 0 to 4096 px on both axes
POS_BITS = 16                     # 65536 steps across the world
STEP = WORLD_SIZE / (2 ** POS_BITS - 1)
ENTITIES = 20
TICKS = 60


# ------------------------------------------------------------------ snapshots and deltas
def snapshot(world):
    """A COPY of the world: later changes to the world must not reach old snapshots."""
    return {eid: dict(fields) for eid, fields in world.items()}


def make_delta(baseline, current):
    """What changed from baseline to current: changed fields, new entities, removals."""
    changed = {}
    for eid, fields in current.items():
        old = baseline.get(eid, {})
        diff = {k: v for k, v in fields.items() if old.get(k) != v}
        if diff:
            changed[eid] = diff
    removed = [eid for eid in baseline if eid not in current]
    return {"changed": changed, "removed": removed}


def apply_delta(baseline, delta):
    world = snapshot(baseline)
    for eid in delta["removed"]:
        world.pop(eid, None)
    for eid, diff in delta["changed"].items():
        world.setdefault(eid, {}).update(diff)
    return world


# ------------------------------------------------------------------ quantization
def quantize(value, lo=0.0, hi=WORLD_SIZE, bits=POS_BITS):
    """Map a float in lo..hi to an integer 0..2**bits - 1 (clamped, so it always fits)."""
    top = 2 ** bits - 1
    q = round((value - lo) / (hi - lo) * top)
    return max(0, min(top, q))


def dequantize(q, lo=0.0, hi=WORLD_SIZE, bits=POS_BITS):
    return lo + q / (2 ** bits - 1) * (hi - lo)


def pack_position(x, y):
    return struct.pack("!HH", quantize(x), quantize(y))   # H = unsigned 16-bit: 0..65535


def unpack_position(data):
    qx, qy = struct.unpack("!HH", data)
    return dequantize(qx), dequantize(qy)


def quantized_delta_size(delta):
    """Bytes for a delta where each changed position is 4 bytes and the rest is JSON."""
    size = 0
    for eid, diff in delta["changed"].items():
        rest = {k: v for k, v in diff.items() if k not in ("x", "y")}
        size += 2                                           # entity id as unsigned 16-bit
        if "x" in diff or "y" in diff:
            size += len(pack_position(diff.get("x", 0.0), diff.get("y", 0.0)))
        if rest:
            size += len(json.dumps(rest).encode("utf-8"))
    return size + 2 * len(delta["removed"])


# ------------------------------------------------------------------ the run
def run(seed=1):
    rng = random.Random(seed)
    world = {i: {"x": rng.uniform(0, WORLD_SIZE), "y": rng.uniform(0, WORLD_SIZE), "hp": 100}
             for i in range(ENTITIES)}
    velocity = {i: (rng.uniform(-120, 120), rng.uniform(-120, 120)) for i in range(ENTITIES)}
    acked = {}                        # the client's last acknowledged world (starts empty)
    client_world = {}
    sizes = {"full": 0, "delta": 0, "packed": 0}
    dt = 1 / 30
    for tick in range(TICKS):
        for eid, fields in world.items():
            if eid % 4 == 0:          # a quarter of the entities stand still
                continue
            vx, vy = velocity[eid]
            fields["x"] = max(0.0, min(WORLD_SIZE, fields["x"] + vx * dt))
            fields["y"] = max(0.0, min(WORLD_SIZE, fields["y"] + vy * dt))
        if tick == 30:
            world[3]["hp"] = 55       # something besides position changes once
        delta = make_delta(acked, world)
        sizes["full"] += len(json.dumps(world).encode("utf-8"))
        sizes["delta"] += len(json.dumps(delta).encode("utf-8"))
        sizes["packed"] += quantized_delta_size(delta)
        client_world = apply_delta(client_world, delta)
        acked = snapshot(world)       # the client acknowledged this tick: it is the new baseline
    worst = max(max(abs(client_world[e]["x"] - world[e]["x"]),
                    abs(client_world[e]["y"] - world[e]["y"])) for e in world)
    qx, qy = unpack_position(pack_position(world[1]["x"], world[1]["y"]))
    quant_err = max(abs(qx - world[1]["x"]), abs(qy - world[1]["y"]))
    return sizes, worst, quant_err


def main():
    sizes, worst, quant_err = run()
    for name in ("full", "delta", "packed"):
        print(f"{name:>9}: {sizes[name]:7d} bytes over {TICKS} ticks "
              f"({sizes[name] / sizes['full']:.0%} of full)")
    print(f"Client world matches the server: {worst < 1e-9}")
    print(f"Quantization step {STEP:.4f} px; error {quant_err:.4f} px "
          f"(within half a step: {quant_err <= STEP / 2 + 1e-9})")
    print(f"Bits needed for 0.1 px across {WORLD_SIZE:.0f} px: "
          f"{math.ceil(math.log2(WORLD_SIZE / 0.1 + 1))}")


if __name__ == "__main__":
    main()

📓 Learning Journal

Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:

  • Key concepts you learned today
  • Techniques that clicked (and the ones that haven't, yet)
  • Questions or confusion to bring to the next session
  • Ideas to try in your own game
  • Progress and feelings: how did this reading go for you?

✍️ This reading's prompts:

  1. List the messages in a game you'd like to make, and sort them into "fine to lose" and "must arrive".
  2. Which of the four savings (deltas, quantization, interest, budget) would help your game most, and how would you measure it?

📝 Summary

Deltas send only what changed since a baseline, and they work only if that baseline is a real copy of what the client acknowledged. Quantization stores each number in just the bits its range and precision need, and must be sized so every value fits. Interest management sends each client only nearby entities, found with a grid and confirmed with a distance check, and a priority budget spreads the rest over time. A small resend-until-acknowledged layer carries the few messages that must arrive, restarting its timer on every resend.

🎓 Key Takeaways

  • Compute deltas against the client's last acknowledged snapshot, stored as a deep enough copy.
  • Size quantization from the range and precision: ceil(log2(range / precision + 1)) bits, clamped.
  • Grid lookups find candidates; an exact distance check decides who is in range.
  • Reliable messages need sequence numbers, acknowledgments, duplicate detection, refreshed resend timers and a retry limit.

🔭 Looking Ahead

That completes Real-time Multiplayer. Next, in Lobby Server & Client, you build rooms, ready checks and a pygame lobby on the framing layer from Framing & Concurrency.

❓ Common Questions

Do I need any of this for a small game?

Often not. A few players and a few dozen objects fit comfortably in plain JSON snapshots on most connections. Measure your bytes per second first; reach for these techniques when the number is too big for your players' connections.

Why not just compress every snapshot with zlib?

General compression helps, and you can combine it with everything here. It works best on large, repetitive data, though, and it can't know that 0.01 px doesn't matter or that a player can't see the far side of the map. Deltas, quantization and interest management remove data compression can't.

What happens when a client joins in the middle of a match?

Its acknowledged baseline is empty, so its first delta is the whole world (every entity is "new"). After it acknowledges that, it gets normal deltas like everyone else.

Can quantization make two clients disagree?

Yes, if the server simulates with full-precision floats while clients see quantized ones. One fix is to quantize the server's own state after each tick too, so what it simulates and what it sends are the same numbers.

🎯 Quick Quiz

Question 1: A server stores baseline = world and later computes make_delta(baseline, world). What happens?

Question 2: Over UDP, which snapshot should the next delta for a client be computed against?

Question 3: Positions run from 0 to 1000 px and are packed as int(x * 100) with '!h'. What goes wrong?

Question 4: Why does the interest check measure the distance after the grid lookup?

Question 5: A reliable sender forgets to refresh a message's resend time after resending it. What happens?

🌟 Going Further

  • Acks with loss: in the exercise, drop 20% of deltas with a random.Random(seed), keep a baseline per acknowledged tick, and check that the client still ends up correct.
  • Priority budget: give each entity a priority from its distance to player 0 and send only the top 8 changes per tick. How long until a distant entity updates?
  • Bit packing: pack health (0–100) into 7 bits and a facing angle into 9 bits, sharing one 16-bit field.
  • Read the docs: struct format characters lists the size and range of every packed type.
  • Coming up in Game Dev III: Advanced: the Lobby Server & Client sends room lists that change only now and then, a natural place to try deltas.