Back to Blog
DOOM artwork
Open-Source DOOM

Open-Source DOOM: Real-Time Multiplayer With No Servers Required

How we took Cloudflare's browser-based DOOM, ripped out the servers, rewrote the netcode, and shipped a 4.2MB file that turns any chat into a deathmatch arena.

In May 2021, Cloudflare did something wonderful. They took DOOM - the 1993 game that defined an entire genre - compiled it to WebAssembly, wired up WebSocket multiplayer through their Durable Objects edge platform, and wrote a fantastic blog post about it. As Celso Martinho put it:

"Running Doom is effectively the new 'Hello, World' in computing."

We loved it. And then we thought: what if we could take it further?

What if multiplayer DOOM didn't need Cloudflare's servers - or anyone's servers? What if it didn't need the internet at all? What if you could send a 4MB file to a friend in a chat message and be fragging each other within seconds, purely peer-to-peer, with the game feeling like a modern real-time shooter instead of a 1994 LAN party?

That's what we built. We call it Open-Source DOOM.

It's powered by a stack of open technologies: Chocolate Doom compiled to WebAssembly via Emscripten, distributed as a WebXDC mini-app, with peer signalling encrypted over Nostr via Marmot (MLS), and game packets transported and encrypted peer-to-peer through Iroh (QUIC-based gossip). Every layer is open-source. Every packet is encrypted. No servers required.

What Cloudflare Built (And Why It's Great)

Credit where it's due. Cloudflare's doom-wasm project did the hard work of porting Chocolate Doom - the faithful open-source recreation of the original DOOM engine - to WebAssembly using Emscripten. That alone is a serious engineering effort. They then wrote net_websockets.c, a transport adapter that replaced DOOM's original IPX/UDP networking with WebSocket connections routed through Durable Objects on Cloudflare's edge network.

Their architecture looked like this:

Player A (Browser) ← WebSocket → Cloudflare Edge (Durable Object) ← WebSocket → Player B (Browser)

A Durable Object acted as the game room - maintaining a routing table of connected players and forwarding packets between them. Clean, elegant, and a great demo of edge computing.

But there was a catch.

What they kept the same

Cloudflare didn't modify DOOM's actual netcode. The game still used its original lockstep networking model from 1993 - the same protocol designed for four PCs on a local area network connected by coaxial cable. Every player sends their inputs (which keys they pressed) to every other player, every single frame, and the game freezes until everyone's inputs arrive.

"The game only advances when everyone receives the commands from all the other players in the group."
- Cloudflare blog post

This worked in 1993 when your LAN had sub-millisecond latency. Over the internet, through WebSockets, through a routing layer? It meant choppy gameplay, freezes whenever anyone's connection hiccupped, and a gameplay experience that felt more like a slideshow than a shooter.

The Durable Object was also a single point of failure and a centralised dependency. No Cloudflare, no DOOM.

What We Changed (Everything Except the Game Itself)

We forked Cloudflare's doom-wasm project and rebuilt the entire networking stack from scratch. Not just the transport layer - the fundamental model of how the game synchronises state between players.

Cloudflare's DOOM Open-Source DOOM
Transport WebSockets → Cloudflare Edge P2P gossip via Iroh (QUIC)
Server Durable Object (centralised) Auto-elected from players
Sync model Lockstep (1993 original) Real-time hybrid
Damage Simulated locally by all clients Host-authoritative events
NPCs / Monsters Simulated locally by all clients Host-authoritative snapshots
Late join Not supported Fully supported
Internet required Yes (Cloudflare Workers) No (works offline, P2P)
Delivery Website 4.2MB file in a chat message

Let's break down each piece.

1. No Servers, No Problem

The Simple Version

Cloudflare's DOOM needed their servers to work. Ours doesn't need any servers. When you open the game, your device automatically figures out who should be the "host" - no configuration, no IP addresses, no port forwarding, no sign-ups. It just works.

The game runs inside a .xdc file - essentially a tiny 4.2MB zip archive containing the entire game. You literally send it as a file in a chat message. Your friend opens it. You're playing DOOM together. The data travels directly between your devices through the chat app's peer-to-peer channels.

Under the Hood

We replaced net_websockets.c with net_webxdc.c - a transport module that speaks the WebXDC realtime channel protocol instead of WebSockets.

WebXDC is an open standard for sandboxed web apps distributed inside chat messages. The app has zero internet access - no fetch, no XMLHttpRequest, nothing. The only communication channel is webxdc.joinRealtimeChannel(), which gives you an unreliable broadcast pipe to other instances of the same .xdc file in the same chat.

Under the hood (in our primary platform, Vector), this channel is backed by Iroh - a QUIC-based peer-to-peer gossip protocol. Messages travel directly between devices, relayed through lightweight Iroh relay nodes only when direct connections aren't possible. There are no game servers, no routing tables, no Durable Objects.

Peer signalling happens over Nostr - the decentralised social protocol. When you open a game, your Iroh node address is published as a Nostr event so other players can find you and establish a direct connection. This signalling layer is encrypted end-to-end via Marmot, which implements MLS (Messaging Layer Security) over Nostr. Once peers connect, Iroh's QUIC transport encrypts the game packets themselves - every position snapshot, every damage event, every frag - before they ever leave your device.

The wire format is minimal:

[to: uint32 LE (4 bytes)][from: uint32 LE (4 bytes)][doom_payload]

JavaScript routes incoming packets by destination UID - only packets addressed to you (or broadcast address 0) get delivered to the WASM engine. Everything else is silently dropped.

2. Who's the Server? Magic.

The Simple Version

In a normal online game, someone runs a server, and everyone connects to it. In Open-Source DOOM, there is no predetermined server. When you open the game, all players silently negotiate who becomes the host. The person who opened the game first wins. This happens automatically in about three seconds, and you never even notice it.

Under the Hood

Server election uses a dead-simple timestamp protocol over the broadcast channel:

  1. Every instance broadcasts 4 magic bytes ([42, 42, 42, 42]) every 300ms: "I exist, who's the server?"
  2. Every instance responds with: [43, 43, 43, 43][padding(4)][timestamp(8)] - where timestamp is Date.now() from when the app first opened
  3. The instance with the earliest timestamp wins
  4. After 3 seconds with no earlier challenger, you declare yourself server
  5. The elected server re-broadcasts its beacon every 3 seconds so late-joiners can discover it

The server gets instanceUID = 1 (hardcoded). Clients get a random UID in 1–65534. This UID becomes their network address for the entire session - no DNS, no IP addresses, no NAT traversal headaches.

The whole election algorithm is about 80 lines of JavaScript. It works over any broadcast transport. No configuration needed.

3. From Lockstep to Real-Time

The Simple Version

Original DOOM multiplayer works like a group of people writing a letter round-robin. Nobody can write their next line until everyone has received and read the previous line. If one person is slow, everyone waits.

We changed it to work more like a live conversation. Everyone talks at their own pace. If you miss a word, you can still follow along because the speaker periodically summarises where things stand. The result feels like a modern shooter - smooth movement, responsive controls, no freezing.

Under the Hood

This was the most fundamental change: replacing DOOM's pure lockstep synchronisation with a hybrid real-time model. The ticcmd backbone remains (it's too deeply embedded in Chocolate Doom's deterministic physics to remove), but we layered three correction systems on top.

Position Snapshots + Exponential Smoothing

Every 2 tics (~57ms at 35 FPS), each player broadcasts a snapshot of their state: position, angle, velocity, attack state, and latency. Remote players don't run DOOM's physics at all. Instead, each tic:

  1. Extrapolate the target position forward using stored momentum
  2. Smooth toward the target, closing 60% of the remaining gap each tic
  3. Teleport detection: if the delta exceeds 128 map units, snap instantly (player respawned or hit a teleporter)
// Close 60% of the gap each tic = smooth exponential convergence
#define INTERP_FRAC  39322  // 0.6 * 65536 (fixed-point)
mo->x += FixedMul(target_x - mo->x, INTERP_FRAC);
mo->y += FixedMul(target_y - mo->y, INTERP_FRAC);

One critical detail: angle is NOT interpolated. The ticcmd's angleturn field is applied deterministically by the physics engine and stays in sync across machines. Interpolating angle toward a stale snapshot fights the ticcmd, causing visible rotation jitter of up to 90°. We learned this the hard way.

Attack Animation Sync

Remote players' attacks are driven by snapshots, not ticcmds. When a snapshot reports an attack, we trigger the animation and play the weapon fire sound. An 8-tic animation lock prevents local state transitions from overriding it. A last_received_attack[] array breaks feedback loops by storing the raw received flag, not the lock-modified state.

4. "I Shot You!" "No You Didn't!"

The Simple Version

In the original DOOM, every computer runs its own copy of the game physics. When you shoot someone, your computer calculates the damage, and their computer calculates the damage, and because the game is in lockstep, they always agree.

With our real-time model, that guarantee vanishes - your screen and theirs might show slightly different positions. So we made one player (the host) the referee. When you shoot someone, you tell the host "I hit Player 2 for 50 damage". The host checks the physics, applies the damage if it's valid, and announces the result to everyone. One truth, no arguments.

Under the Hood

We implemented an event-based host-authority model for all game-changing state:

Client A fires weapon → bullet hits Player B locally
  → Send DAMAGE_EVENT to host: {target: B, damage: 50}
  → Apply visual feedback only (screen flash, attacker tracking)
  → Do NOT reduce Player B's health

Host receives DAMAGE_EVENT:
  → Set damage_from_event = true (bypass remote-source skip)
  → Call P_DamageMobj() with full physics
  → Broadcast result via HEALTH_AUTH packet

All clients receive HEALTH_AUTH:
  → Apply as ground truth

The same pattern applies to USE events (doors/switches), respawns, and kill notifications. The host is the single source of truth for anything that changes the game state.

5. Making Monsters Agree

The Simple Version

DOOM has dozens of monsters per level, each running their own AI - chasing you, shooting fireballs, infighting with each other. In the original game, every computer simulates every monster identically (because lockstep ensures they all see the same inputs). In our version, only the host simulates the monsters. Everyone else just sees the results - like watching a puppet show where only the puppeteer knows the script, but the audience sees the performance in real-time.

Under the Hood

We built a full NPC synchronisation system. Every monster and barrel gets a unique net_id via a registry. The host broadcasts compact snapshots every 2 tics:

  • Per NPC (20 bytes): net_id, position (x, y, z), angle, animation state, health, flags
  • Per sector: ceiling and floor heights (for doors/lifts)
  • Per missile: source, type, position, velocity, angle (for fireballs/rockets)

Clients skip physics entirely for all NPCs - only animation timers run. State changes use P_SetMobjStateNoAction(), which applies the visual state WITHOUT executing action functions. This prevents clients from independently spawning projectiles, running AI decisions, or playing duplicate sounds.

6. "Room for One More?"

The Simple Version

Original DOOM didn't let you join a game already in progress. Everyone had to be there at the start, or tough luck. Our version lets players drop in mid-game. You open the .xdc, the game finds the server, and you spawn in - even if everyone else is already knee-deep in the dead.

Under the Hood

Late joining required solving several hairy problems:

  • Tic synchronisation: Late joiner's tic counter is meaningless - they weren't there for tics 0 through N. The server sends the current tic as start_tic and the client aligns its counters.
  • Double-slot bug: The assignment system could give the new client a slot that was already used. We explicitly clear pre-existing slots before assigning.
  • Ghost bodies: Disconnected players left invisible collision obstacles. We now clean up any existing body before spawning.
  • Name propagation: Every existing player re-broadcasts their name so the joiner's HUD shows the right names.

7. Fits in a Chat Message

The Simple Version

The entire game - engine, levels, monsters, weapons, networking, touch controls, gamepad support, all of it - fits in a 4.2 megabyte file. That's smaller than most photos your phone takes. You send it in a chat message like you'd send a meme. Your friend taps it, and they're in the game. No app store, no downloads, no accounts, no updates.

Under the Hood

The .xdc format is just a ZIP archive with a different extension:

File Size Purpose
vector-doom.wasm ~2.7 MB DOOM engine (Emscripten, -O3)
doom1.wad ~1.7 MB Shareware levels (freely distributable)
vector-doom.js ~160 KB Emscripten runtime (minified)
index.html ~12 KB UI, touch controls, gamepad support
webxdc-net.js ~3 KB Server election + packet routing

The build pipeline: Emscripten compiles Chocolate Doom + our modifications to WASM, webxdc-net.js is injected as --pre-js, JS is minified with terser, and everything is zipped at maximum compression.

From Keypress to Frag

Here's what happens when you press the fire button on your phone:

  1. Your finger hits the Fire button (HTML touch event)
  2. JavaScript calls inject_key_event(0, 32) - keydown, spacebar
  3. DOOM's event queue receives ev_keydown
  4. G_Responder() builds a ticcmd with BT_ATTACK
  5. Your weapon fires, P_LineAttack() traces a hitscan ray
  6. Bullet hits Player 2 → send DAMAGE_EVENT to host
  7. Your position snapshot broadcasts via realtimeChannel → Iroh gossip → peer devices
  8. Host receives damage event → validates → applies → broadcasts HEALTH_AUTH
  9. Player 2's screen: health drops, pain flash plays
  10. Your screen: the host's HEALTH_AUTH confirms the kill

Total time from keypress to kill confirmation: roughly 100–200ms depending on network conditions. No servers touched. No corporation involved. Just two chat apps talking directly to each other.

Standing on the Shoulders of Giants

None of this would exist without:

And a special note: the networking architecture of Open-Source DOOM was designed and implemented as a collaboration between a human developer and an AI (Claude, by Anthropic). Not generated and pasted - collaborated on. Hundreds of iterations, debugging sessions with hex dumps of gossip packets, heated debates about whether to interpolate angles (don't), and moments of genuine surprise when things just... worked.

Try It

Open-Source DOOM is free, open-source, and available today.