DEV Community

Cover image for Delta encoding multiplayer game state
Arjen
Arjen

Posted on Originally published at oldlight.io

Delta encoding multiplayer game state

Old Light is a browser strategy game where a tab can stay open for days. The client holds a full copy of the galaxy state it is allowed to see, and the server keeps that copy honest by sending patches: every change arrives as a world.delta message the client merges into what it already has. Sending changes instead of resending state is textbook delta encoding. What that leaves open is what a game state patch actually holds, and why the patch a rival receives is not the one you receive.

I covered how the stream starts (one snapshot on connect, then deltas) and the time-math traps inside it in the networking post. This post is about the delta itself.

What goes in a game state patch

When people say delta encoding they usually mean byte diffs: compare two versions of a blob, ship the difference. That requires the sender to know which version the receiver holds. A game server broadcasting to thousands of sockets can't afford that; tracking a per-client "last known state" and diffing against it on every change would be more expensive than the update.

So an Old Light delta states facts about players and sectors instead:

interface WorldDelta {
    added?: { players?: Player[] };
    removed?: { playerIds?: string[] };
    updated?: {
        players?: Player[];
        sectors?: Sector[];
        dirtySectors?: SectorCoord[]; // map data here went stale, refetch it
        tradeBoard?: TradeBoardDelta; // the market board moved
        deals?: DealsDelta; // a negotiation moved; only its two parties get this
    };
    serverNow: number;
}
Enter fullscreen mode Exit fullscreen mode

A delta says a player joined, an id is gone, a player's row changed, or a sector's public map data went stale. The last two fields carry no payload. They say a surface moved, a client with that surface open goes and reads it, which keeps a busy marketplace off every socket that isn't looking at one. The server can emit the identical message to every socket without knowing what any of them currently holds, and the client can apply it to whatever it has. It also tells the renderer exactly what to repaint: a players update touches the roster and HUD, a dirtySectors entry touches the hexes in the named sectors. Nothing else on screen is redrawn.

Absolute values, never increments

Every value in a delta is the new total, and the client assigns it, with no arithmetic against the value it already holds. When your empire's score moves, the payload contains the score, and the merge is a replace keyed by id:

const incoming = new Map(delta.updated.players.map((p) => [p.id, p]));
this.players = this.players.map((p) => incoming.get(p.id) ?? p);
Enter fullscreen mode Exit fullscreen mode

(Simplified; the per-field rules come next.) Absolute values make the patch idempotent. A message delivered twice lands on the same state. A message that never arrives leaves the client stale but not corrupted, and the next update of the same entity repairs it completely, because that update is also the full truth rather than a step in a sequence. Increments would need exactly-once, in-order delivery to stay correct, and a browser tab that sleeps for hours and reconnects on a different network is the wrong place to bet on that.

The same event is a different delta for every viewer

Delta encoding collides with information hiding here. In Old Light, who owns which star is public, but what's inside an empire is not: building composition, income, fleet stacks, and treasury are visible only to the owner (until a rival scouts them). A change to your empire therefore produces two different patches from one event.

The public version goes to the galaxy-wide room that every socket joins, including anonymous spectators, and it passes through a scrub function first:

function scrubForBroadcast(player) {
    const out = {
        id: player.id,
        name: player.name,
        kind: player.kind,
        spawn: player.spawn,
        score: player.score,
        owned: player.owned.map(publicHex), // coord, name, score, buildings: []
        // anything not listed is not sent: credits, transits, unread counts...
    };
    // promoted to public by an explicit decision, and carried only when set
    if (player.protectedUntil) out.protectedUntil = player.protectedUntil;
    if (player.banner) out.banner = player.banner;
    return out;
}
Enter fullscreen mode Exit fullscreen mode

The scrub is an allowlist. Every public field is listed explicitly and anything unlisted is dropped by default, so a field added to the player type six months from now stays private until someone consciously promotes it. The two conditional lines were each promoted on purpose. A rival sees the beginner-protection countdown because otherwise a refused attack looks like a bug, and the crest is cosmetic.

The private version of the same event, with the economy, build queue, fleet, and credits populated, goes only to a room containing your own connections. The server also orders the two emits, scrubbed global first and your rich view last, so the thin version can never overwrite the detailed one on your own screen. From the protocol's point of view, a rival's client is synchronised with a smaller galaxy than yours, one holding only the facts the two of you share publicly.

A missing field keeps its old value

Two versions of the same player on the same event channel create a merge problem. Your own client sits in the galaxy room like everyone else, so when the server broadcasts a scrubbed update about you, your client receives a copy of yourself with no credits field and an empty owned list. Blindly replacing your local player with that would wipe your empire off your own screen until the next rich view arrived.

The merge rule that prevents it: undefined means "not sent", never "cleared".

credits: incoming.credits !== undefined ? incoming.credits : local.credits,
owned: incoming.owned.length > 0 ? incoming.owned : local.owned,
Enter fullscreen mode Exit fullscreen mode

A field the scrub stripped keeps its locally held value; an explicit value, including null, overwrites. The empty-owned rule exists because a public broadcast carries owned: [] by design (rival hex lists travel through the map data channel, not the roster), so an empty list must never be read as "this empire lost everything".

There's a second merge subtlety on captures. When a star changes hands, the delta names the new owner with the hex now in their list, but the previous owner isn't in the payload at all. The client walks every other player it knows and strips any hex the freshly delivered player now claims: last writer wins, keyed by coordinate. Without that pass, both empires would render as owning the same star until the next full snapshot.

Ship the fact, derive the rest

A lot of what the player sees is never on the wire. Territory is the clearest example: every occupied star projects an empire's borders a fixed radius outward, and where two empires overlap, the older claim wins. The server could ship the resulting shapes, hundreds of hexes for a large empire, on every change. Instead the delta carries only the occupied stars, and the client recomputes the projection from them. Client and server run one rule over one set of inputs, so they can't disagree. The patch stays a few hundred bytes however large the borders it implies.

dirtySectors goes further and ships an invalidation instead of data. An early version of the client refetched the whole visible map region after every delta, which at scale meant every economy tick anywhere triggered viewport-sized fetches everywhere. Now a delta that changes star ownership names the map sectors that went stale, and the client refetches only those, through the same public request any client could make anyway. Deltas that change no geometry, which is most of them, trigger no fetch at all.

When a client misses one

There is no delta journal and no sequence numbering. If a client disconnects, it doesn't ask for the deltas it missed; on reconnect the server sends a fresh snapshot and the client throws its incremental state away, because every missed change is already baked into the new snapshot. The dormant star cache deliberately survives that reset. It holds the unclaimed stars the snapshot never carries, and dropping them would blank most of the map until the first region fetch came back, which looks like a bug.

I could have kept a journal and replayed it. The snapshot code has to exist anyway, since every fresh connect needs one, and it is already scrubbed per viewer. A retained event log would need the same per-viewer filtering applied retroactively to every reconnecting client, over a gap of unknown length.


The game this comes out of is Old Light, a strategy game across a galaxy that runs in a browser tab.

Top comments (1)

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The idempotency claim holds for every field except the one that is not a replace: owned merges as incoming.owned.length > 0 ? incoming.owned : local.owned, which leaves an empty list unrepresentable. That is the value your own rich view has to produce once a player is down to zero stars, and it arrives shaped exactly like the broadcast the rule exists to ignore, so the stale hexes survive until the next snapshot. The capture pass does not catch that one either, since it only strips coordinates another player's payload now claims. Worth noting the post reads two ways on the trigger: the prose says a public broadcast carries owned: [], while scrubForBroadcast populates it from player.owned.map(publicHex).