Doom in the Browser, Coordinated by the Edge

Every serious engineering organization has corridors where good sense goes to die. Cloudflare is no exception. When our CTO, John Graham-Cumming, floated the idea of porting multiplayer Doom to run on our edge network, it was less a question of whether it could be done and more a question of what it would prove.

The underlying argument is architectural. Traditional client-server splits force developers to choose where their logic lives. Code on the client is interactive but insecure and slow to update. Code on a centralized server is secure and easy to patch but suffers from latency. The edge is a third option: code that runs close to the user, in a controlled environment, that can be updated without the user’s involvement. Games—interactive, latency-sensitive, and demanding—are a perfect stress test of that model.

So we set out to prove it. The result is a fully browser-based, multiplayer Doom, coordinated by Cloudflare Workers and Durable Objects. And it’s all open source: the Wasm port, the website and message routing code.

The Port: From DOS to WebAssembly

Doom has been ported to everything from pregnancy tests to spectrum analyzers, so a browser port is almost routine. The real challenge was finding a codebase that included the network layer. Existing WebAssembly ports of Doom didn’t handle multiplayer, so we had to do it ourselves.

We settled on Chocolate Doom, a modern port that faithfully reproduces the original DOS version and, critically, supports networked multiplayer with a clean, modular codebase. Using Emscripten, we compiled it to WebAssembly within a few days. Seeing Doom running in a browser window felt like magic.

Getting there wasn’t without its headaches. Here are the three biggest engineering challenges we hit—and how we solved them.

No main() Loop

Browser environments are event-driven. You can’t run an infinite game loop that blocks the page. Emscripten provides emscripten_set_main_loop() to run your loop at 60 frames per second without hanging other browser functions between iterations. In d_main.c, the original call to D_DoomLoop() became:

emscripten_set_main_loop(D_RunFrame, 0, 0);

No UDP

Original Doom used Novell’s IPX protocol; modern ports use UDP. Games prefer UDP because it’s fast and non-blocking, even if you have to handle packet loss and ordering yourself. But Emscripten only emulates POSIX TCP sockets over WebSockets—no UDP support. And we didn’t want UDP anyway; we wanted WebSockets, which use TCP.

That meant writing a new Chocolate Doom network module to tunnel UDP-style point-to-point traffic over WebSockets while coping with TCP’s inherent reliability overhead.

The net_websockets.c Driver

The network topology was the first hurdle. In the original LAN model, one player is the server and the others connect directly via UDP, requiring public IPs and special firewall rules. We wanted zero-config, browser-based play where all players connect to a single WebSockets server acting as a message router.

We emulated the old model by generating a fake IP address at startup—a random uint32_t—and creating a routing table on the client that maps the fake IPs discovered via the WebSocket protocol to actual connections. To avoid blocking Doom’s game loop, we buffer incoming packets in an intermediate queue between the asynchronous WebSockets layer and the game logic. Every time a packet arrives, it’s queued asynchronously; when the game loop asks for new packets, it instantly drains the queue, mimicking UDP’s responsiveness. Outgoing traffic moves via NET_Websockets_SendPacket() and emscripten_websocket_send_binary() using a simple envelope containing the 4-byte “From” and “To” fake IPs, followed by the original Doom packet. The message router on Cloudflare Workers maintains its own connection table, using those address headers to deliver each message to the right player.

We also added a few quality-of-life options, such as setting your name on the command line and binding multiple keys to the same action. The compiled code and instructions are in the doom-wasm repository.

BLOG-511 Embedded Image - oOgK5Z

Routing Through Durable Objects

The message router lives on Cloudflare's edge and handles all traffic between clients and the game server. Its responsibilities break down into five pieces: accepting WebSocket connections, maintaining a routing table that maps connections to "From" addresses, parsing incoming messages, broadcasting to the right clients, and exposing REST APIs for creating and validating Doom rooms.

Cloudflare Workers provides the WebSocket handling, while Durable Objects supply the shared state the router needs across connections. WebSockets give us persistent, low-overhead bidirectional channels—exactly what real-time applications like games require. A Durable Object is essentially a class in the Worker code that holds data and the methods to manipulate it; you interact with it through the standard Fetch API.

The default Worker class simply hands everything off to handleApiRequest():

export default {
    ...
    return handleApiRequest(url.pathname, request, env)
    ...
}

Each multiplayer session gets a unique ID—a "room"—that maps to its own Durable Object instance. That ID appears in the URL when a player invites friends to join. The handleApiRequest() function validates the room ID in the request URL and forwards it to the corresponding Durable Object.

There's a subtle performance consideration here. Workers offers two ways to generate a Durable Object ID: newUniqueId(), which is very fast, and idFromName(), which derives an ID from an arbitrary string. The latter is convenient but requires a global network lookup, which is expensive. This project uses newUniqueId() to construct room IDs.

async function handleApiRequest(path, request, env) {
  ...
  switch (parts[1]) {
    case 'ws':
    case 'room':
      room = await checkRoom(parts[2], env)
      if (room) {
        let id = env.router.idFromString(room)
        let routerObject = env.router.get(id)
        return routerObject.fetch(path, request)
      }
    case 'newroom':
      room = await createRoom(env)
      return jsonReply({ room: room }, 200)
  }
}

Room ID creation looks like this:

async function createRoom(env) {
  const room = env.router.newUniqueId().toString()
  const digest = await crypto.subtle.digest({ name: 'SHA-256' },
    new TextEncoder().encode(room + env.DOOM_KEY),
  )
  const hash = Array.from(new Uint8Array(digest))
  const hex = hash.slice(0, 4).map(b => b.toString(16)
    .padStart(2, '0'))
    .join('')
  return `${room}-${hex}`
}

Once that's done, the router takes over WebSocket message handling. The webSocket.addEventListener('message') handler fires on every incoming message and performs three operations. First, it decodes the packet:

let data = msg.data
let from = new Uint32Array(data.slice(4, 8))[0]
let to = new Uint32Array(data.slice(0, 4))[0]

Then it registers new clients in a routing table—a list pairing WebSocket objects with their corresponding "From" IDs from the Doom protocol scheme:

// if it's a new client, add it to the table of clients
if (this.sessions.map(c => c.from).indexOf(from) == -1) {
    let session = { ws: webSocket, from: from }
    this.sessions.push(session)
}

Finally, it forwards the message to the destination client identified by the "To" field:

// send this packet to the corresponding client
i = this.sessions.map(c => c.from).indexOf(to)
if (i != -1) this.sessions[i].ws.send(data.slice(4))

The full router source and setup instructions are in the GitHub repository, alongside a simplified Node.js implementation for local development.

The Front End

The website ties everything together and runs on Cloudflare Pages. It has four jobs: execute the Wasm Doom binary with input arguments adjusted for context (multiplayer host, guest, deathmatch, cooperative, solo); deliver a clean setup and join flow; provide user feedback while interacting with Doom; and carry that 90s dark aesthetic.

Running Wasm Doom

Emscripten Wasm binaries use a global JavaScript object called Module as their configuration surface. When the app boots, it reads Module's definitions and applies them across execution stages. Module supports defining input arguments (like command-line flags), pre-startup code, stdout handlers, and more.

This project uses PreRun to load two required files—doom1.wad and default.cfg—into Emscripten's virtual file-system before Doom launches. Without them, the game won't start:

preRun: () => {
    Module.FS.createPreloadedFile("", "doom1.wad", "doom1.wad");
    Module.FS.createPreloadedFile("", "default.cfg", "default.cfg");
}

The site's JavaScript shows how Module is configured and how the page talks to both Wasm Doom and the router APIs. Feedback from the running game is displayed in a scrolling ticker beneath the canvas. Rather than building a more elaborate bridge between the page and the Wasm process, the team parses stdout messages with a small handcrafted protocol:

BLOG-511 Embedded Image - ZCkDhz
doom: 1, failed to connect to websockets server
doom: 2, connected to %s
doom: 3, we're out of client addresses

On the C side of Doom, the corresponding changes are minimal:

net_websockets.c: printf("doom: 2, connected to %s\n", attr.url);

Joining a Game

Setup is intentionally simple. A player goes to silentspacemarine.com, starts a multiplayer session, receives a unique permalink, and shares it. The host acts as the network server, though that's handled transparently. Guests just click the link, pick a name, and wait for the host to start.

BLOG-511 Embedded Image - 2vMEHk

Source and Wrangler configuration for the site live in the doom-workers repository.

Limitations and Takeaways

The demo at silentspacemarine.com runs Wasm Doom in the browser with mouse, sound, and fullscreen (press F) support. It works on desktop and mobile (with virtual gamepads) and supports up to four players over the Cloudflare edge.

Several known rough edges remain. Player disconnects—say, closing the browser tab—aren't handled gracefully, and Durable Object storage isn't used to persist sessions across the unlikely event of isolate termination.

Doom's original 1994 network protocol also shows its age. Every client receives complete input from all other clients, and the game only advances once everyone's commands arrive. Playability is thus tied to the slowest connection. Modern FPS protocols address this with client-side prediction, compression, delta updates, and related techniques, as detailed in "The DOOM III Network Architecture."

The point, however, was to demonstrate what's now possible at the edge. Source code for Wasm Doom, the message router, and the website is open-sourced on GitHub, packaged to run locally or deploy on Cloudflare. Contributions are welcome.