← Engineering

GZDoom in the Browser

April 13, 2026
TLDR: We ported GZDoom — a modern OpenGL-based Doom engine with its own scripting VM, audio pipeline, and rendering architecture — to the browser using AI agents. The technical problems were real and plentiful. What we didn't solve was music. That's its own story. Play GZDoom in your browser.

What Makes This Hard

GZDoom is not a simple game. It ships its own OpenGL renderer (which had to become a WebGL/GLES renderer). It has ZMusic, a full audio middleware layer that handles MIDI playback, OPL synthesis, and PCM streaming. It has a ZScript virtual machine. It has autosegs — a linker section trick that auto-registers subsystems at startup by scanning memory between two symbols, a pattern that has no equivalent in WebAssembly's linear memory model.

None of this is impossible to port. But each piece required its own surgery, and the surgeries interact in ways that only show up at runtime.

The Threading Model Dictates Everything

The fundamental architectural decision for any Emscripten port is the threading model. GZDoom is multi-threaded and needs real pthreads, which means SharedArrayBuffer, which means the server must respond with Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers. That part is table stakes.

The less obvious consequence: we used PROXY_TO_PTHREAD, which offloads the C++ main() to a worker thread so the browser's main thread stays free for the event loop. This is the right call — without it, the game blocks rendering — but it breaks SDL's event delivery. SDL_PollEvent simply doesn't fire on a worker thread. Input events live on the main thread and have no path to the game.

The fix was a shared-memory ring buffer. JavaScript on the main thread captures keyboard and mouse events and writes them into a lock-free circular buffer in WASM memory. The game thread reads from the same buffer on each frame. Eight bytes per event. It works well, but it's an entirely new input subsystem that the original codebase has no knowledge of.

The Things CMake Does Not Know About Emscripten

A less glamorous but time-consuming class of problem: build system assumptions.

find_package(Threads) expects a linkable system library. With Emscripten, pthreads are compiler flags, not a library. CMake's thread detection fails silently and everything that links against Threads::Threads gets the wrong flags. The fix is inelegant but effective — manually assert all the CMake thread variables before the detection runs.

Stack size was another quiet failure. GZDoom's default 1MB stack is fine natively. In the browser, with GLES call proxying happening across threads, it wasn't. We bumped both the main stack and the default pthread stack size to 8MB before we stopped seeing unexplained crashes.

GZDoom's autosegs mechanism registers engine subsystems by placing pointers in named linker sections and scanning between two boundary symbols at startup. WASM's linear memory model doesn't preserve custom linker sections. The scanner finds nothing, the subsystems never register, and the engine silently misbehaves. This required switching to explicit registration.

The Renderer: Canvas Has Its Own Opinion About Its Size

GZDoom uses the GLES renderer path in the browser, which meant using Emscripten's WebGL context instead of a native GL context. The wrinkle: SDL reports window dimensions based on its internal state, which doesn't always match what the canvas element actually is. On high-DPI displays or after certain resize events, the two diverge.

The fix: stop asking SDL for dimensions. Use emscripten_get_canvas_element_size directly to query the canvas, and make sure the WebGL context has proxyContextToMainThread set to EMSCRIPTEN_WEBGL_CONTEXT_PROXY_FALLBACK so GL calls route correctly.

Mouse capture had its own issues. The browser's Pointer Lock API doesn't behave identically to SDL's native mouse capture. The fix involved tracking capture state explicitly and synthesizing the right SDL events rather than relying on SDL's own focus detection.

Sound Effects vs. Music: A Tale of Two Systems

Sound effects worked almost immediately. GZDoom routes SFX through OpenAL, and Emscripten's OpenAL implementation bridges into the Web Audio API cleanly. One wrinkle: Emscripten's Web Audio bridge throws an exception if it receives NaN or Infinity as an audio parameter value. Added guards on the relevant paths and SFX was stable.

Music was a different problem. GZDoom's music pipeline goes through ZMusic, a separate library that handles format detection, MIDI synthesis, and OPL emulation. In the browser, this pipeline would start, run for a while, and then silently die.

The fix was to bypass OpenAL for music entirely. We replaced the streaming buffer path with a direct Web Audio implementation: a ScriptProcessorNode fed by a shared-memory ring buffer. This worked. Music played. And then it didn't — cutting out mid-level, chopping every half-second under certain conditions. The SFX work is clean and committed. The music fix is in an uncommitted diff.

Good Enough

This is the tension that doesn't come up in technical postmortems: when does "good enough" become the actual answer?

The game runs. SFX works. The renderer is stable. Saves persist. You can play through levels. Music works intermittently. Fixing it fully would require going deeper into ZMusic's internals, or rethinking the ring buffer timing, or both.

There's an argument that shipping incomplete work with honest documentation is more useful than not shipping at all. There's another argument that a game engine without music is meaningfully broken and the bar should be higher. Both are reasonable. We landed where we landed.