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

One Simulation, Many Cursors: Bolting Online Co-op onto an ECS

Last post I told you td-survivors was done, ten posts and a wrap, "see you when it looks like a game." Then I added online co-op. This is a post about co-op. I contain multitudes, and also I lied.

Here is the thing that surprised me, and the reason this post exists: co-op was easy. Not "easy" in the way developers say something was easy right before they explain the six weeks of suffering. Actually easy. A weekend. And the reason it was easy is the exact same reason post 10 existed at all: the whole game is a pile of pure functions operating on arrays of numbers, with no classes and no this. It turns out that if you build a game that way, the netcode is mostly already written, you just haven't noticed yet.

The trap everyone falls into: lockstep

The obvious way to do multiplayer for a deterministic-looking tower defense is lockstep: both machines run the same simulation, you only send each other the inputs, and since you both run the same code on the same inputs you both get the same result. Clean. Elegant. A total lie for this game.

The sim runs on dt, a variable floating-point time delta, and it does thousands of float multiplications per frame. JavaScript floating point is not guaranteed to be bit-identical across machines, browsers, or even CPU moods. So on your machine an enemy is at pathDist = 4.0000001 and on mine it's at 3.9999998, and that is enough. One frame later your tower fires and mine doesn't, and now our two simulations have quietly walked into different universes, holding hands, never to reconcile. Lockstep desyncs are the stuff of gamedev horror stories, and I was not going to write a determinism-audit rig for a game whose art is still placeholder squares.

So: no lockstep. Instead, one machine is right and everyone else is watching.

One authoritative server, many thin clients

The model is dead simple. A headless Node process runs the real simulation, the single source of truth. Both browsers are thin clients: they send what the player wants to do, and they render whatever the server tells them the world looks like. Your browser is not simulating anything. Your browser is a very expensive way to look at someone else's arrays.

And here's where the ECS discipline pays off for free. Remember the whole per-frame simulation from last post, spawn, move, shoot, die, hoover XP? That's one function:

export const stepSim = (w: GameWorld, dt: number): void => {
  const g = w.game
  g.time += dt
  spawnSystem(w)
  movementSystem(w, dt)
  // ...status, shooting, projectiles, death, xpOrbs, leak, levelUp
}

The rule that made single player testable, the sim imports zero LittleJS, no rendering, no input, no audio, was written months before co-op was even a plan. But it means stepSim runs completely happily in Node, where there is no canvas and no speakers and no mouse. The server just calls it on a timer:

room.tick = setInterval(() => {
  for (const intent of batch) applyIntent(w, intent)
  if (w.game.phase === 'playing') stepSim(w, (1 / TICK_HZ) * w.game.speedMult)
  broadcast(room, { t: 'snapshot', s: encodeSnapshot(w, room.stageId) })
}, TICK_MS)

Thirty times a second: apply everyone's queued inputs, advance the one true simulation by one tick, then mail everybody a photo of the result. That's the whole server loop. The single-player game and the multiplayer server run the same simulation code, byte for byte, because there was never a second copy to keep in sync. There's one stepSim, and it does not care whether it's being called by a browser's game loop or a Node setInterval in a datacenter.

Inputs are intents, and cursors are needy

Clients don't touch the world. They send intents, a tiny serializable vocabulary of "things a player can want":

export type Intent =
  | { kind: 'cursor'; slot: number; x: number; y: number }
  | { kind: 'placeTower'; slot: number; towerType: TowerTypeId; x: number; y: number }
  | { kind: 'pickCard'; slot: number; index: number }
  | { kind: 'nextWave'; slot: number }
  | { kind: 'cycleSpeed'; slot: number }
  // ...move, re-aim, tower queue, reroll, banish, pause

Every intent carries a slot: which player is you. The beautiful part is that applyIntent is the same code path in single player and co-op. In single player, your mouse click produces a placeTower intent that gets applied to your local world immediately. In co-op, the identical intent gets shipped over a WebSocket, and the server applies it to the real world. Same function, same result, one written once. The server just does one paranoid thing: it overwrites the slot on every incoming intent with the sender's actual slot, so you cannot mail it a placeTower claiming to be your friend and building towers out of their inventory. (I would absolutely do this to a friend. That's why the server doesn't trust me.)

The needy one is cursor. Thirty times a second, your browser tells the server exactly where your mouse is, like a very anxious pen pal who has never heard of personal space. That's how your teammate sees your little cursor gliding around the map. It's also how the game knows whose cursor grabbed which XP orb, because XP in co-op is competitive: the orbs go to whoever's cursor is closest, and the levels are per-player. Co-op td-survivors is a cooperative game right up until a purple 100-XP orb drops, at which point it is a footrace and a betrayal.

Snapshots: the ECS makes serialization boring

Here's the part where "no OOP, just arrays" stops being a stylistic preference and starts being a cheat code.

The server has to describe the entire world to the clients, thirty times a second. In an object-oriented engine that's genuinely annoying: you've got a graph of enemy objects pointing at tower objects pointing at projectile objects, all tangled with this references and methods, and you have to walk it, flatten it, strip the behavior, and reassemble it on the other side without accidentally shipping a function or an infinite loop.

In an ECS, the world is already flat. It's already Enemy.hp = [ ...numbers ], Position.x = [ ...numbers ]. A snapshot is basically "read the arrays." encodeSnapshot walks the entity queries, pulls the fields the clients actually need, and dumps them into JSON. applySnapshot on the client writes those numbers back into its own shadow world's arrays. There is no object to reconstruct because there was never an object. The enemy was always just the number 7 and an agreement about which arrays to read, and that agreement serializes perfectly, because it's nothing.

The only real subtlety is that bitECS recycles entity ids, so eid 7 might be an enemy on the server and a projectile in the client's shadow world. So each entity gets a stable network id namespaced by kind, and a little NidMap keeps "server enemy 7" pointing at "my local enemy 41" across ticks. That's the one piece of genuine bookkeeping in the entire feature, and it's about forty lines.

Where do the explosions come from?

If the server sends nothing but numbers, who fires the particles?

This was the one design decision that took actual thought, and it's the thing I'm proudest of. The simulation emits no presentation. None. There is no spawnParticle call anywhere in stepSim, no sound, no screen shake. The sim is a pure state machine that only knows how to change numbers. That's non-negotiable, because the server has no screen to shake and no speakers to pop, and I refuse to maintain two versions of the sim.

So every effect in the game is derived on the client by watching the numbers change. One file, deriveFx.ts, diffs the world every frame and infers the drama:

  • An enemy's hp dropped since last frame? Spawn a damage number, play a hit sound.
  • An enemy vanished near the end of the path? That's a leak: shake the screen.
  • An enemy vanished anywhere else? That's a death: burst of particles.
  • A tower's lastFire ticked forward? It just shot: muzzle flash and a bang.
  • A projectile disappeared? Explosion or spark, depending on the tower.
  • An XP orb vanished right next to a cursor? Pickup chime.

The magic is that this is the exact same deriver in single player and co-op. In single player it watches your local world tick by tick. In co-op it watches the shadow world get patched by snapshots. It cannot tell the difference and it does not need to, because "a number changed, therefore something happened" is true either way. Screen shake, which used to be a value inside the sim, got evicted and moved to the client where it belongs, because a headless server shaking a screen that does not exist is the setup to a philosophy joke I chose not to make.

Drop in, drop out, no one's in charge

The last nice thing: there is no player cap and no lobby ceremony. You make a room, you get a code and a ?room=CODE link, and the host can start whenever, even alone. Anyone can join at any time, including mid-run: the server just appends a slot, grows the running world by one player, and mails the newcomer a started message to boot them straight into the live simulation. Growing the world is, once again, boring: append one entry to the players array. The arrays don't care.

Leaving is just as calm. Your slot gets tombstoned, connected = false, so everyone stops drawing your cursor, but your towers stay, because towers are shared world entities and they never belonged to you personally. The run continues for everyone else. The room only dies when the very last person leaves and turns off the lights. There's no host migration because after START the host has no special powers anyway; "host" is just whoever clicked create, an honorary title with no benefits.

So did it all just work?

Mostly, insultingly, yes. The scary part, the part with a capital-N Netcode reputation, was anticlimactic, because the architecture had already done the hard work months earlier under a different name. The bugs I did hit were gloriously mundane and not netcode at all: the guest couldn't see their level-up cards for a build or two (a UI-plumbing miss, the cards were there, the guest just wasn't being shown them), and I had to remember to freeze the sim on the server when a run ends so both players see the same game-over screen instead of the host's world quietly playing on. No desyncs. No "it works on my machine and diverges on yours." No determinism audit. Because there is one machine, and it is right, and everyone else is politely watching its arrays.

That's the whole trick, and it's the same trick as the whole game: keep the state in flat arrays, keep the logic in pure functions, and let one loop be the truth. Do that, and multiplayer stops being a rewrite and becomes a shipping address.

Okay. Now the series is done. Probably. See you when it looks like a game, possibly with two cursors on it.