Old Light Play now
All posts

Engineering

Game networking for a slow strategy game

Empire borders and named sectors on the Old Light galaxy map

Almost everything written about game networking is about fast games. The classic material covers UDP and client-side prediction, in service of squeezing sixty snapshots per second through a home connection. Very little of it applies to the game I'm building.

Old Light is a multiplayer browser strategy game. A fleet takes hours to cross the galaxy and your economy keeps growing while the tab is closed. If a click takes 300ms to round-trip, nothing in the game can tell. The hard part is different: staying correct over long stretches of time, for thousands of empires at once.

One snapshot, then deltas#

The client opens a WebSocket. The server answers with one message, world.init: everything this player is allowed to see, plus the server's current clock. After that, every change arrives as a world.delta, a small patch the client merges in.

socket.on("world.init", (payload) => {
    state = new GameState(payload.world);
    clockOffset = payload.serverNow - Date.now();
});
socket.on("world.delta", (delta) => state.applyDelta(delta));

Those two handlers are the whole protocol. Nothing needs sequence numbers or an interpolation buffer.

The fiddly part is the boundary between the two. A delta can fire while the snapshot is still being built. The server handles its side by building world.init before joining the socket to any broadcast room, so a mid-build delta never reaches the new socket, and the snapshot already includes that change. The client handles its side by buffering deltas that arrive before its state exists and draining them after boot. Skip either half and a player who connects at the wrong millisecond sees a world that disagrees with the server.

One more thing in that snippet: clockOffset. The server stamps its clock into every payload, and the client measures the difference once at boot. Everything time-based runs on server time from then on, because the player's machine might be minutes off.

The bug where both sides do time math#

Resources in Old Light aren't ticked by a timer. As covered in the backend post, the server computes them on demand: last saved balance, plus rate times elapsed time. The client runs the same formula so your counter ticks up smoothly between messages.

So a resource value on the wire carries a number plus an anchor timestamp to project it forward from. The trap sits in the timestamp. When the server answers a request, it projects the stored balance to now. But the database row still carries the timestamp from the last actual save, maybe hours ago. Send the fresh number with the stale anchor and the client projects again on top:

// wrong: fresh balance, stale anchor
return { balance: projectedToNow, settledAt: row.settledAt };

// client side, moments later:
display = balance + rate * minutesSince(settledAt); // re-adds hours of income

The same hours of income get counted twice. Nothing errors out. The counter runs ahead of the truth and snaps back whenever a real server value arrives. The fix is a blunt rule: a projected value's anchor is the moment of projection, never the row's timestamp. A regression test pins it down with settledAt === now.

In a shooter, this bug would be visible within a frame. Here it only shows up if you compare the counter against a hand calculation across hours, so it ships unnoticed and gets reported as "my resources jumped".

The server plays on without you#

A common shortcut in this genre is to let your building "finish" whenever some request happens to arrive after its timer expired, usually your own next login. Old Light finishes the build at the second it's due, whether or not anyone is connected.

Queue a build and the server sets an in-memory timer. When it fires, the outcome is written to the database and broadcast to your socket room. If you're offline the broadcast lands in an empty room and evaporates. The database write still happened, though, so a rival probing your system an hour later sees the upgraded reality.

In-memory timers die with the process, so every boot re-arms the pending ones and settles any job that completed while the server was down, as of its original completion moment. The main read path also settles overdue work before answering. Even a missed timer can't serve stale state.

What a broadcast is allowed to cost#

With no tick rate, nearly all network cost is broadcasts, so each one has a budget: one emit per player action. When someone signs up, the server spawns their empire plus two AI empires around them. All three announcements travel as one delta, not three fanouts.

Rooms do the scoping. Public changes, like a star changing owner, go to a galaxy-wide room every socket joins. Private state, like your income and build queue, goes only to player:<id>.

Building that private view is the expensive part, so the server checks the room first and skips the work when nobody is connected. The AI empires complete builds constantly and never have anyone connected, so without the check most of the enrichment work would be for empty rooms.

The netcode I never wrote#

Old Light has no client-side prediction and no reconciliation. The client is a viewer. Every mutation is a request the server may refuse, and the client waits the round trip to see the result. An action's real feedback loop is minutes or hours, so an extra 300ms before the queue entry appears is invisible.

That deletes the hardest problems in the genre's literature. There's no state to roll back, since the client never speculated, and nothing is aimed, so lag compensation has no job to do. Plain TCP WebSockets, socket.io in our case, are fine; a delayed packet delays a UI update instead of a dodge.

What's left is bookkeeping. Every value on the wire is a ledger entry, and the remaining bugs are clerical: an anchor stamped at the wrong moment, or a timer nobody re-armed after a restart. Mistakes like that never show up in the next frame. They surface ten hours later, when a player opens the tab and the number doesn't match their own arithmetic.