How Yours Truly, The Warlord and Survive the Night got online co-op
· 14 min read
By Phil · building Legitsauce
This one started with my brothers. We live in different cities now, the kind of distance where you keep meaning to catch up and then a month slips by, and the thing that used to reliably put us in the same room was a game. I had spent a long stretch turning Legitsauce into a shelf full of games you play by yourself, and one night it landed on me that I had never once been able to sit down and play any of them with the two people I learned to play games next to. Nobody wrote in asking for co-op. There was no feature request. I just wanted to fight a horde with my brothers.
The trouble is that multiplayer runs straight into the one rule the whole site stands on. A game on Legitsauce is supposed to cost me nothing to run once it ships: the engine and the game data sit on a CDN, your browser does all the work, and there is no server anywhere with my name on the bill. The obvious way to add multiplayer is to stand up a game server, and a game server is a process that runs forever and charges me by the hour whether my brothers are online or not. So for a long time I did not build it, because I could not see how to do it without quietly turning a free hobby into a monthly invoice.
Then I found a way that costs me almost nothing. I built it into Survive the Night, our 3D zombie survival game, got it running in production, and then turned the same machinery loose on Yours Truly, The Warlord, the town-defense game, to prove the netcode generalized to a completely different shape of game. Both are live now. You click Multiplayer, you Host or you Join with a six-letter code, your squad shows up on a staging screen by gamertag, and you all drop into one shared run. I have lost a good number of evenings to it with my brothers since, which was the entire point.
This post is the long version of how that works, down to the individual bytes that move between the players, with the actual wire format laid out below.

The constraint that picked the architecture
The architecture was decided by the budget, and not the other way around. The rule for the whole catalog is that I do not run a stateful, authoritative game server. That single rule knocks out the textbook answer immediately, and what remains has to thread a needle: players need to find each other, and their games need to exchange a lot of fast-moving state, and I am not willing to pay for a machine that holds that state on my side.
The answer has two pieces that do very different jobs. There is a tiny relay that exists only so two browsers can find each other and shake hands. After the handshake, the players talk straight to each other, peer-to-peer, and my infrastructure never sees a single gameplay packet. The relay is a Cloudflare Worker backed by one small Durable Object per game code. It holds a little text (who is in the lobby, what the connection offers and answers are) for the few seconds a handshake takes, and then it goes quiet. It is stateless in every way that matters to my wallet. There is no tick loop on it, no physics, no horde. It is a switchboard operator who connects your call and hangs up.
Once the call is connected, the players carry the whole game between themselves.
One decision that everything hangs on: is the game realtime?
Before writing a line of transport code I had to answer one question, and the answer decides almost everything else. Is the game realtime? By realtime I mean continuous movement, aiming, physics, combat, the kind of thing that streams positions many times a second and feels awful the instant it lags. Both of these games are squarely in that camp. Survive the Night has you running and shooting. Yours Truly, The Warlord has units and enemies pouring across a field in real time.
A realtime game cannot ride a plain HTTP relay, and I know this because I tried. Polling a relay over HTTPS adds a hundred to two hundred milliseconds of round trip, it stalls on TCP head-of-line blocking the moment a packet is late, and it bills bandwidth on every single message. For a turn-based card game that is completely fine and I happily use exactly that path for low-frequency games. For a shooter it is miserable. You feel every poll.
Realtime forces the harder road: peer-to-peer over WebRTC, using UDP-style DataChannels that can drop a late packet instead of waiting for it. That road has a toll. Roughly fifteen percent of players, especially anyone on mobile or behind carrier-grade NAT, cannot open a direct peer connection, so for them the traffic has to bounce through a TURN relay to get out of their network. I pay for that sliver of TURN bandwidth, and I decided that was an acceptable cost in a way that a full game server never was. When even TURN cannot make a connection, the game tells the player plainly that it could not connect, rather than quietly dropping them onto a laggy fallback that just feels broken.
The shape of a session: a host-authoritative star
Every player runs their own full copy of the game. One of them is the host, and the host is the single source of truth for everything that has to be shared and has to agree: the horde, the boss, the buildings, the score. Guests do not simulate that shared world at all. They send up their own little slice of state (where their avatar is, what it is doing) and they render whatever the host streams back down.
The connection topology is a star with the host at the center. Guests do not talk to each other directly. A guest sends its frame to the host, and the host, acting as the hub, both folds that frame into its own picture and forwards a copy to every other guest. That is how guest B gets to see guest A move, without guests ever needing a full mesh of connections between themselves.
That design has a useful side effect. Because the host owns anything that must agree, there is never a question about whose version of the score is right, or whether a zombie is alive on one screen and dead on another. The host decides, and everyone else is looking at a slightly delayed photograph of the host's truth. Cheating gets harder too, almost for free, because a guest can describe its own avatar but it cannot reach in and edit the horde.
What actually crosses the wire
The shared world moves as snapshots. Fifteen times a second the host captures its authoritative world and serializes it into a compact binary blob, and the guests deserialize that blob and draw it. Fifteen hertz sounds slow, and on its own it would look slow, but the guests interpolate between the two most recent snapshots so the motion you see runs at your monitor's full frame rate. I will come back to that smoothing trick. First, the bytes.
A snapshot is sent as a single DataChannel frame. The very first eight bytes are the sender's id as ASCII, so the receiver knows whose frame this is. Everything after that is the snapshot codec's output.
The header is twelve bytes and it is the same every frame.
Two fields there are doing more work than they look. The tick counter always advances, even while the game is paused, and that is on purpose: a guest watches the tick to know the host is still alive, and a tick that stops climbing is the only reliable sign that the host closed the tab or crashed. More on that when I get to the bugs. And present_mask is the trick that keeps these frames small.
present_mask, or how not to send what hasn't changed
The world has eight kinds of thing in it, and they do not all change at the same speed. Enemies move every frame. Buildings mostly sit there. Trees basically never do anything until someone chops one down. Sending all eight arrays in every frame would waste most of the bytes on data the guest already has and that has not budged.
The header carries a one-byte mask, one bit per array. If the bit is set, that array is in this frame. If the bit is clear, the array is simply not there, and the guest keeps the copy it already had. The host can stream enemies and projectiles at the full fifteen hertz while only occasionally bothering to resend the buildings or the tree health. The guest never sees a stale-looking empty list, because an omitted array means "no news," not "everything is gone."
Each array that is present writes a two-byte count and then that many fixed-size records. Fixed-size matters: because every enemy record is exactly seventeen bytes, the decoder can bounds-check the whole array against the buffer length up front and bail cleanly on anything malformed, which is important on a channel that is allowed to deliver the occasional garbled packet.
One enemy, byte by byte
Let me open up the single most common record in the stream, the enemy, which is seventeen bytes.
The quantization is where the savings live. A position in metres is a 32-bit float, four bytes per axis, and most of those bits describe a precision no player will ever perceive. The codec throws that away. It takes the playable area, which runs from minus eighty to plus eighty metres on each axis, and maps it onto a 16-bit integer. That is two bytes per axis instead of four, and the resolution that survives is roughly two and a half millimetres, which is far finer than anything you can see on a moving enemy in a browser tab. Facing gets even more brutal treatment: a full rotation is squeezed into a single byte, two hundred fifty-six possible directions, and you cannot tell the difference. Health rides as a fraction from zero to one in one byte, because the guest only needs to draw a health bar, not run the damage math.
That state byte is my favorite bit of penny-pinching. An enemy's AI state needs three bits. Whether it is a boss is one more bit. Rather than spend a whole byte on each, both pack into the same byte, AI state in the low three bits and the boss flag up in bit three. The guest pulls them apart on the way in. None of this is clever in isolation, but multiply it across a hundred and fifty enemies fifteen times a second and it becomes the margin between a frame that fits in one datagram and one that has to be split.
Guests talk back, but quietly
Everything above flows downhill, host to guest. Traffic also goes the other way, and it has a very different character depending on the game, which is where reusing the netcode got interesting.
In Survive the Night a guest is mostly an avatar in the world, so the guest streams its own one-avatar snapshot up to the host at the same fifteen hertz, using the same codec, just describing one player instead of a whole horde. The host folds it into the shared world and forwards it on.
Yours Truly, The Warlord is a town-defense game, and a guest there does two jobs at once. They are a body running around, and they are a second pair of hands building the town. Those building actions cannot ride the fifteen-hertz snapshot, because the snapshot overwrites itself every frame and a one-shot command like "build a tower here" would be lost the instant the next frame replaced it. So commands go up a separate, reliable channel as small text messages, and they are delivered exactly once.
The detail I am quietly proud of: the host does not trust the command to say who sent it. It re-derives the owner from the id of the connection the message arrived on. A guest can ask to build its own tower, and it physically cannot forge a command as another player, because it does not control the field that names the actor. The host fills that in.
The control channel does the boring, load-bearing work
Alongside the binary snapshot traffic, every connection carries a reliable text channel for the things that have to arrive and arrive in order. This is the channel that runs the handshake, keeps the roster in sync, starts the run, and measures lag. It is unglamorous and it is where most of the bugs lived.
The latency readout is a small thing I like. The host pings each guest about every one and a half seconds over the reliable channel, and the ping carries the host's own clock reading. The guest does nothing but echo that exact number straight back. When the echo returns, the host subtracts and gets a true round trip, measured start to finish against a single clock, so there is no clock synchronization to get wrong. Then the host broadcasts the whole latency table back out, which means every player's signal-strength panel shows the same numbers, because they all come from the one clock that is allowed to measure them.
Fifteen snapshots a second, sixty frames a second
I promised to come back to the smoothing. The host sends position updates fifteen times a second. Your screen draws sixty or more times a second. If the guest just slammed each new snapshot onto the screen the instant it arrived, every enemy would visibly teleport four times a second, a stutter that no amount of pretty art can hide.
The fix is interpolation. The guest always keeps the two most recent snapshots and renders a moment slightly in the past, somewhere between those two known positions, advancing smoothly toward the newer one as real time passes. You are always watching the world about one snapshot behind live, on the order of sixty-odd milliseconds, and in exchange the motion stays smooth at full frame rate. The slow-moving things, buildings and economy and tree health, skip the interpolation entirely and just apply at snapshot rate, because nobody perceives a tree's health bar updating on a fifteen-hertz cadence. The trick is to spend the smoothing budget only where the eye is actually tracking motion.
Making two players harder than one
Adding a second player to the same wave count would just turn the game into a cakewalk. Difficulty has to scale with the size of the squad, and the host, being the authority on the world, is the natural place to do it.
The host reads the live squad size every frame and feeds it into the spawn math. Enemy counts and the cap on how many can be alive at once climb with each extra player, so two players face close to one and three-quarters the pressure of a solo run and three players face about two and a half times. The boss does not get cloned into a second boss, which would wreck the camera and the HUD; instead it gets meaningfully more health and summons more help per extra player, so it stays one readable fight that simply demands more from the group.
The economy goes the other way and stays personal. Every survivor earns and spends their own points. The difficulty lives in the enemy count and the enemy health, so keeping the upgrade currency per-player means each person's choices still feel like theirs, and nobody is squabbling over a shared pool while zombies eat the town.
One more rule that sounds obvious and absolutely was not, the first time a guest accidentally shot a host in the back: friendly fire is off by construction, not by a runtime check. Remote teammates are drawn with no collider and no damageable hitbox at all, so a weapon's raycast passes clean through a friend and can only ever hit an enemy. I did not want a branch in the damage code that says "if the target is a teammate, skip." I wanted it to be physically impossible to register the hit in the first place.
The bugs that only showed up in production
The first version passed every test on my machine and then fell over in ways that only a real Cloudflare Worker and a real second computer could produce. These are the ones worth writing down so I never pay for them twice.
The 201 that pretended to be a 200. When a host creates a lobby, the production Worker answers with HTTP 201 Created. My local development stand-in answered with 200. My transport, reasonably enough, checked for 200 to decide the lobby was made. Every test I ran locally hosted perfectly. In production, hosting silently failed, because 201 is not 200, and the only difference between the two environments was a status code I had hard-coded an assumption about. The fix was one line: accept any 2xx. The bigger fix was the habit it forced on me. The local stand-in and the real Worker now have to pass the same tests, and I run the whole connection flow against the deployed Worker before I trust that anything works.
The frozen world that thought it was connected. When a host closes the tab, the connection does not always politely announce that it died. The relay keeps handing the guest the host's last stored snapshot, so the guest sits inside a perfectly intact, perfectly motionless world, completely convinced it is still connected. The game looks fine. It is just over. This is why the tick counter never stops climbing, even while paused. The guest watches the tick, and the moment it stops advancing, a countdown starts: a gentle warning after a few seconds in case it is only a wifi hiccup, and a hard call that the host is gone after eighteen seconds. Without that, "the host quietly left" looks identical to "the game is still running."
The host that gave up right before the guest arrived. A WebRTC connection only makes progress if you service it on every single frame: the path-finding between the two browsers, the encrypted handshake, and the data channel all advance a little on each poll. My first version had a connect timeout that, once it fired, stopped polling the peer. The problem showed up with a host sitting alone in a freshly made lobby, waiting for a friend. It would reach the timeout, stop servicing the connection, and then when the friend finally typed the code and joined, the channel never finished opening. The guest could see the lobby but could never actually drop in. The fix was to keep servicing every peer on every frame no matter what, and to start the timeout clock only once a real handshake is already in progress.
localhost is not 127.0.0.1. The headless Chromium I use to test the whole flow cannot open an IPv6 loopback, and "localhost" resolves to IPv6 first on a lot of systems. Addressing everything by the literal 127.0.0.1 instead made a class of test failures evaporate. A silly afternoon went into that one.
Proving it works without a room full of laptops
I cannot keep four phones and three friends on call to test a netcode change. The proof comes in three layers, and a change is not done until all three are green. The bottom layer is plain headless unit tests: the snapshot codec round-trips, the difficulty math, the roster ordering, and the transport itself driven through a fake relay to a connected state. The middle layer launches several real headless browser contexts at once, drives each one through the genuine menus and staging screen, and asserts that everyone connects, that the squad shows every gamertag, and that each player can see the others move. The top layer runs that same multi-browser flow against the real production Worker rather than the local stand-in, which is the only thing that ever catches a gap like the 201, and after paying for that one once, I do not skip it.
Two games out of one library
The reason I built this in Survive the Night first and then in Yours Truly, The Warlord is that the second game is the real test of whether any of this generalized. The transport, the relay protocol, the roster, the latency panel, the interpolation, the snapshot framing, all of that is shared and went in untouched. The one per-game piece is the codec's entity layout, the part of this post with all the byte tables, because a zombie survival game and a town-defense game simply have different things in their worlds. Survive the Night cares about players and zombies and bullets. Yours Truly, The Warlord cares about all of that plus buildings, villagers, caravans, trees, and a shared research tree. So the mask grows new bits and the records change shape, while everything underneath them stays put.
The reuse is what makes the whole thing sustainable. The hard, scary, easy-to-get-wrong parts (the WebRTC handshake, the host-as-hub forwarding, the liveness detection, the production-only failure modes) were written and debugged once. Adding co-op to the next realtime game is mostly a question of describing its world in bytes and tuning how the difficulty scales. The expensive lesson became a cheap recipe, which is what lets a one-person catalog ship something like this at all.
Go play it with someone
Both games are free in the browser, no download, no account. Open Survive the Night or Yours Truly, The Warlord, hit Multiplayer, host a lobby, and text the six-letter code to whoever you actually want to play with. That is the whole reason this exists. Every position you watch your friend run to, every tower they drop, every zombie the host rules dead, took the trip through the bytes above, and if it feels smooth then the quantization and the interpolation did their jobs. Call your brother. I did.