Doug's Game Dev Log
About
2026-08-03

The Server Stopped Repeating Itself

Today's td-survivors work is mostly about the co-op server learning some manners: instead of shouting the entire game state at every client thirty times a second, it now only mentions the parts that actually changed. Along the way the multiplayer server also grew a memory, and a couple of enemies and towers got poked.

If you missed how the co-op server works, the short version: one headless Node process runs the real simulation, and both browsers are thin clients that render snapshots. Which is elegant right up until you look at what those snapshots cost.

The server was very, very repetitive

Every tick, at 30Hz, the server was serializing the whole world to JSON and blasting it to each client. Every enemy's position, every tower's fields, every projectile, every XP orb, every frame, whether or not any of it had changed. Most of the world does not change between two frames 33 milliseconds apart. I was paying full price to tell everyone that the tower in the corner is still, in fact, a tower in the corner.

So now the server sends a delta: a field-level diff against the previous tick's snapshot. If an enemy moved, you get its new position. If it didn't, you get nothing about it. Shared scalars and per-player state still go in full because they're tiny; the big entity arrays are the part that gets diffed. To keep late joiners and reconnects sane, the server also sends a full keyframe every 2 seconds, and forces one the moment anyone joins, so a fresh client always boots from a complete world instead of trying to reconstruct reality from a stream of "the goblin moved 0.3 units left."

The codec lives in a new pure net/delta.ts (diffSnapshot / applyDelta, TDD'd, because getting a diff codec subtly wrong is the kind of bug that ruins a weekend). The client just folds each delta back into a running full snapshot and then renders exactly like before. The rendering layer never learned that anything changed, which is the nicest kind of refactor.

The numbers, because this is the fun part

On a real 2-player run I measured it: ~49% less bandwidth per client, 202 KB/s down to 103 KB/s. Roughly half.

Here's the counterintuitive bit I did not expect. Server CPU went down too. You'd think diffing every entity every tick would cost you, and it does, about 12 microseconds per tick, which is 0.04% of the frame budget, i.e. nothing. But the payload you hand to JSON.stringify is now much smaller, and stringifying less stuff is cheaper than stringifying everything. The savings on serialization more than pay for the diff. Net result: about 8 microseconds saved per tick. Less bandwidth and less CPU, which almost never happens at the same time. I'm choosing to enjoy it instead of getting suspicious.

The server grew a memory

Related theme: the co-op server used to be aggressively amnesiac. Last time I noted its glorious blind spot, it can't read your save, so everyone joined as a blank slate with zero banishes and no idea who they were.

That's better now. Every save has a permanent playerId, and clients send a small profile (unlocks, banish/reroll levels) when they create or join a room. The server reuses your slot when your playerId reconnects, so if you drop and come back you resume with your XP, level, and inventory intact instead of respawning as a stranger. Tower and upgrade rolls are now per-player too: a player who unlocked a tower gets offered it while the others don't. Enemy HP also scales with the party, 1.5^(N-1) at spawn time, so two players don't just faceroll a solo-tuned wave (already-spawned enemies never get buffed, only new spawns). And I fixed the server hanging on Ctrl+C, which was the least glamorous but most personally satisfying bug of the day.

While I was in there

A few gameplay tweaks rode along. The shieldbearer now wears 5 concentric shield rings that visibly drop one at a time as it takes hits, so you can actually read how close it is to cracking. The poison tower got reworked from an AoE pulse into a single-target poison dart: it fires every 1.5s at the next un-poisoned enemy, deals no impact damage, and its poison now ticks discretely without piercing, which means each tick peels exactly one shield ring off a shieldbearer. Poisoned enemies also turned purple, because green-on-green was a crime. Stage 1 got a dedicated brood-swarm wave and a refreshed schedule to go with the rebalanced enemies.

Next time: I'd still love tower ideas for a game about zapping cheerful microbes, and I want to see how far the delta trick scales once there are more than two cursors on the board. The server has stopped repeating itself. Now I get to find out how much it can carry.