
Running DOOM in Browser with React using WebAssembly
Try It Now!
Before diving into the technical details, experience DOOM running in your browser powered by WebAssembly:
Introduction
Imagine you have a C program from 1993 that draws pixels to a screen and reads keyboard input. How do you run it in a modern web browser? This is the challenge WebAssembly solves.
In this guide, we’ll take Chocolate Doom—a faithful recreation of the original DOOM game engine—and make it run in your browser. Along the way, you’ll learn how WebAssembly bridges two completely different worlds: C programs that expect direct hardware access, and web browsers that only understand JavaScript and web APIs.
Why This Matters
Before diving into code, let’s understand why this knowledge is valuable:
- Legacy Code Migration: Thousands of C/C++ applications can be brought to the web
- Performance: Near-native speed for computationally intensive tasks
- Code Reuse: No need to rewrite existing C/C++ codebases in JavaScript
- Browser as Platform: Deploy anywhere without installation
What You’ll Learn
- The Translation Problem: How C programs that know nothing about browsers can run in them
- The Bridge Pattern: How Emscripten translates system calls to web APIs
- The Integration Challenge: Making WASM modules play nicely with React
- The Developer Experience: What you need to know for future WASM projects
The Conceptual Bridge: C Programs Don’t Know About Browsers
Here’s the fundamental problem: DOOM was written in C for DOS. It expects to:
- Write directly to video memory at address
0xA0000
- Read keyboard interrupts
- Access sound hardware directly
- Have a
main()
function with an infinite game loop
But browsers only offer:
- Canvas API for drawing
- DOM events for input
- Web Audio API for sound
- Single-threaded JavaScript that can’t block
This is where WebAssembly and Emscripten create a bridge:
How WebAssembly Works
Now let’s see how this translation happens in practice. Here’s how Chocolate Doom runs in your browser:
The key components:
- Emscripten: Compiles C code and provides JavaScript APIs
- SDL2: Graphics/audio/input library that Emscripten maps to browser APIs
- Memory Management: WASM runs in a linear memory space managed by the browser
- Main Loop: Browser’s requestAnimationFrame replaces the traditional game loop
How Emscripten Works
Emscripten is an LLVM-based toolchain that compiles C and C++ code to WebAssembly. Here’s the compilation pipeline:
The process works as follows:
- Clang Frontend: Parses C/C++ code and generates LLVM Intermediate Representation (IR)
- LLVM Optimizer: Applies optimizations at the IR level (same as native compilation)
- Emscripten Backend: Instead of generating x86/ARM machine code, it generates:
- WebAssembly Binary (.wasm): The actual compiled code in binary format
- JavaScript Glue Code (.js): Handles module loading, memory management, and API bindings
What makes Emscripten special:
- System Call Emulation: Translates POSIX calls to browser APIs (file I/O → IndexedDB, etc.)
- Library Support: Ports of SDL, OpenGL, OpenAL, and other libraries that map to Web APIs
- Automatic Bindings: Generates JavaScript interfaces to call C functions from JS
- Memory Management: Provides a virtual heap within WASM’s linear memory
Prerequisites
What You Need to Know
- Basic C programming concepts (functions, pointers, compilation)
- JavaScript/React fundamentals
- Command line basics
- How web browsers work (event loop, APIs)
Don’t worry if you’ve never used WebAssembly—that’s what we’re here to learn!
Before we begin, you’ll need a Linux or WSL environment with basic development tools. We’ll install Emscripten, the WebAssembly compiler toolchain.
# Install build essentials
sudo apt update
sudo apt install -y build-essential git python3 cmake
# Install Emscripten
cd ~
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
# Add to your shell config (.bashrc or .zshrc)
echo 'source ~/emsdk/emsdk_env.sh' >> ~/.bashrc
source ~/.bashrc
# Verify installation
emcc --version
Step 1: Clone and Prepare Chocolate Doom
Chocolate Doom is a source port that accurately reproduces the original DOS version of Doom. It’s important to understand that Chocolate Doom is just the game engine—it doesn’t include any game data. To play DOOM, you need a WAD file (Where’s All the Data), which contains the actual game assets like levels, sprites, sounds, and textures.
For this tutorial, we’ll use the shareware version of DOOM (doom1.wad) which id Software released for free distribution. This demo version includes the first episode “Knee-Deep in the Dead” with 9 levels. Other WAD files you can legally use include:
- doom1.wad - Shareware version (4MB, freely distributable)
- freedoom1.wad - Open source alternative compatible with Doom
- Your purchased copies of doom2.wad, plutonia.wad, or tnt.wad
Let’s start by cloning the source code:
# Clone the repository
mkdir -p ~/projects && cd ~/projects
git clone https://github.com/chocolate-doom/chocolate-doom.git
cd chocolate-doom
# Install autotools (needed for configure script)
sudo apt install -y automake autoconf libtool pkg-config
# Generate build scripts
./autogen.sh
Step 2: Apply WebAssembly Patches
Chocolate Doom uses an infinite game loop, but browsers require yielding control back to handle events. We need to patch the main loop to use Emscripten’s browser-friendly approach. Pay attention to the D_DoomLoop
function where we replace the infinite while(1)
loop with emscripten_set_main_loop()
, which yields control back to the browser between frames.
Create a patch file wasm.patch
:
--- a/src/d_loop.c
+++ b/src/d_loop.c
@@ -31,6 +31,10 @@
#include "net_loop.h"
#include "net_sdl.h"
+#ifdef __EMSCRIPTEN__
+#include <emscripten.h>
+#endif
+
// The complete set of data for a particular tic.
typedef struct
@@ -679,6 +683,13 @@ static void D_RunFrame(void)
}
}
+#ifdef __EMSCRIPTEN__
+static void D_EmscriptenMainLoop(void)
+{
+ D_RunFrame();
+}
+#endif
+
void D_DoomLoop (void)
{
if (bfgedition &&
@@ -695,6 +706,11 @@ void D_DoomLoop (void)
I_InitGraphics();
I_EnableLoadingDisk();
+
+#ifdef __EMSCRIPTEN__
+ emscripten_set_main_loop(D_EmscriptenMainLoop, 0, 1);
+ return;
+#endif
TryRunTics();
Apply the patch:
patch -p1 < wasm.patch
Step 3: Configure for WebAssembly
Now we’ll configure the build for Emscripten. The emconfigure
command is a wrapper that sets up the build environment to use Emscripten’s compilers (emcc, em++) instead of the system’s default compilers (gcc, g++). This ensures that all compilation will target WebAssembly instead of native machine code.
# Create build directory
mkdir -p build-wasm && cd build-wasm
# Set Emscripten environment
export CC=emcc
export CXX=em++
export AR=emar
export RANLIB=emranlib
# Configure with WebAssembly flags
emconfigure ../configure \
--host=wasm32-unknown-emscripten \
--disable-dependency-tracking \
--disable-werror \
CFLAGS="-O2 -s USE_SDL=2 -s USE_SDL_MIXER=2" \
LDFLAGS="-s WASM=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s INITIAL_MEMORY=134217728 \
-s NO_EXIT_RUNTIME=1 \
-s FORCE_FILESYSTEM=1 \
-s MODULARIZE=1 \
-s EXPORT_NAME='ChocolateDoom' \
-s EXPORTED_RUNTIME_METHODS='[\"callMain\",\"FS\"]' \
-lidbfs.js"
Key flags explained:
USE_SDL=2
: Includes SDL2 library (handles graphics/audio/input)ALLOW_MEMORY_GROWTH=1
: Lets WASM allocate more memory as neededMODULARIZE=1
: Creates a JavaScript module we can importFORCE_FILESYSTEM=1
: Enables virtual filesystem for game files-lidbfs.js
: Links IndexedDB filesystem for persistent storage
Step 4: Build Chocolate Doom
With everything configured, we can now build the WebAssembly version. The emmake
command is another Emscripten wrapper that ensures the make
build tool uses Emscripten’s compilers throughout the build process. It intercepts all compiler calls and redirects them to emcc/em++.
# Build using all CPU cores
emmake make -j$(nproc)
# The output will be:
# - src/chocolate-doom (JavaScript loader)
# - src/chocolate-doom.wasm (WebAssembly binary)
Step 5: Set Up Your Vite Project
This tutorial assumes you have a working Vite environment (or similar bundler like webpack). Copy the compiled files to your project’s public directory so they can be served as static assets:
# Copy WASM files to your project
cp src/chocolate-doom.js path/to/your/project/public/doom/chocolate-doom.js
cp src/chocolate-doom.wasm path/to/your/project/public/doom/
# Download the shareware WAD to avoid CORS issues
wget https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad -O public/doom/doom1.wad
Configure Vite to exclude these files from optimization. In your vite.config.js
or astro.config.mjs
:
// vite.config.js or astro.config.mjs
export default defineConfig({
// ... other config
vite: {
optimizeDeps: {
exclude: ["chocolate-doom.js"], // Don't process Emscripten output
},
assetsInclude: ["**/*.wasm"], // Treat WASM files as assets
},
});
Step 6: Create the React Wrapper
Now let’s create a React component that properly manages the WASM lifecycle. First, define the TypeScript types:
// doom-types.ts
export interface EmscriptenModule {
canvas: HTMLCanvasElement | null;
arguments?: string[];
preRun: Array<() => void>;
postRun: Array<() => void>;
print: (text: string) => void;
printErr: (text: string) => void;
setStatus: (text: string) => void;
locateFile: (path: string) => string;
TOTAL_MEMORY: number;
ALLOW_MEMORY_GROWTH: boolean;
FS: EmscriptenFileSystem;
callMain: (args: string[]) => void;
pauseMainLoop: () => void;
resumeMainLoop: () => void;
onRuntimeInitialized?: () => void;
onAbort?: (what: string) => void;
}
export interface EmscriptenFileSystem {
mkdir: (path: string) => void;
writeFile: (path: string, data: Uint8Array) => void;
mount: (fs: any, opts: any, path: string) => void;
syncfs: (populate: boolean, callback: (err: Error | null) => void) => void;
filesystems: { IDBFS: any };
}
Step 7: Implement Game State Management
Create a React component that handles loading states and user feedback:
// doom.tsx
import React, { useRef, useState, useEffect } from "react";
import type { EmscriptenModule } from "./doom-types";
// Track if DOOM is already loaded (singleton pattern)
let doomLoaded = false;
interface DoomState {
status: "idle" | "loading" | "running" | "error";
statusMessage: string;
error: string | null;
}
interface ChocolateDoomProps {
wasmPath?: string;
jsPath?: string;
wadPath?: string;
width?: number;
height?: number;
isMuted?: boolean;
}
Step 8: Implement WAD File Caching
WAD files contain all the game data for DOOM—levels, graphics, sounds, and music. The shareware doom1.wad is freely distributable and legal to use. It’s important to only use WAD files you have the right to use:
- doom1.wad (Shareware) - Free to distribute, contains Episode 1
- doom2.wad - Commercial, requires purchase of DOOM II
- freedoom1/freedoom2.wad - Open source alternatives
- heretic.wad, hexen.wad - Other supported games
To avoid downloading the 4MB shareware file repeatedly, implement browser caching:
const ChocolateDoom: React.FC<ChocolateDoomProps> = ({
wasmPath = "/doom/chocolate-doom.wasm",
jsPath = "/doom/chocolate-doom.js",
wadPath = "/doom/doom1.wad", // Serve locally to avoid CORS
width = 640,
height = 400,
isMuted = true,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [state, setState] = useState<DoomState>({
status: "idle",
statusMessage: "Click Start to launch DOOM",
error: null,
});
const [isCached, setIsCached] = useState<boolean | null>(null);
// Check if WAD is cached on component mount
useEffect(() => {
if ('caches' in window) {
caches.open('doom-wad-cache').then(cache => {
cache.match(wadPath).then(response => {
setIsCached(!!response);
});
});
}
}, [wadPath]);
Step 9: Load and Initialize the Game
Handling CORS Issues
When loading WAD files from external servers, you may encounter CORS (Cross-Origin Resource Sharing) errors. This happens because browsers block requests to different domains for security reasons.
For example, loading from https://distro.ibiblio.org
will fail with:
Access to fetch at 'https://distro.ibiblio.org/...' from origin 'https://yoursite.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
Solution: Download the WAD file and serve it locally from your public directory:
# Download the shareware WAD file (4MB)
wget https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad \
-O public/doom/doom1.wad
Then update your component to use the local path:
// Instead of external URL:
// wadPath = "https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad"
// Use local path:
wadPath = "/doom/doom1.wad"
This approach has several benefits:
- No CORS issues
- Faster loading (no external network requests)
- Works offline once cached
- More reliable (no dependency on external servers)
Now let’s implement the main loading logic with proper error handling:
const loadGame = async () => {
// Prevent multiple instances
if (doomLoaded) {
setState({
status: "error",
error: "DOOM is already running. Refresh the page to restart.",
statusMessage: "Already loaded",
});
return;
}
setState({
status: "loading",
statusMessage: "Initializing...",
error: null
});
try {
// Load WAD file with caching
let wadData: Uint8Array | undefined;
if ('caches' in window) {
const cache = await caches.open('doom-wad-cache');
const cachedResponse = await cache.match(wadPath);
if (cachedResponse) {
setState(s => ({ ...s, statusMessage: "Loading from cache..." }));
wadData = new Uint8Array(await cachedResponse.arrayBuffer());
} else {
setState(s => ({ ...s, statusMessage: "Downloading game data (4MB)..." }));
const response = await fetch(wadPath);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await cache.put(wadPath, response.clone());
wadData = new Uint8Array(await response.arrayBuffer());
}
}
Step 10: Configure Emscripten Module
The Emscripten Module is the JavaScript object that controls your WebAssembly program. Think of it as the “control panel” that connects your C code to the browser environment. Here’s what’s actually happening:
What’s a “File System” in WASM?
In WASM context, there’s no real file system—it’s all simulated! When your C code calls:
FILE* f = fopen("/home/user/save.dat", "w");
fwrite(data, 1, size, f);
Emscripten translates this to:
- Store data in a JavaScript object that mimics a directory structure
- Optionally persist to IndexedDB for permanent storage
- Keep everything in the browser’s sandbox—no access to real files
The Abstraction Layers
Here’s the key distinction:
WebAssembly (WASM) Provides:
- Linear memory (like a big array both JS and WASM can access)
- Function imports/exports (calling between JS and WASM)
- Basic types (i32, i64, f32, f64)
- That’s it! WASM is surprisingly minimal
Emscripten Adds:
- System call emulation (open, read, write, etc.)
- Virtual file system with multiple backends
- POSIX-like APIs (pthread, sockets, etc.)
- C standard library (malloc, printf, etc.)
- JavaScript integration helpers
Let’s configure the module with this understanding:
// Configure the Emscripten module
const module: EmscriptenModule = {
// Where to draw graphics (Emscripten will create a 2D context)
canvas: canvasRef.current,
// Command line arguments passed to main(argc, argv)
// Just like running: ./chocolate-doom -iwad doom1.wad -window
arguments: ["-iwad", "/doom1.wad", "-window", ...(isMuted ? ["-nosound"] : [])],
// Functions to run before main() is called
preRun: [
() => {
console.log("[DOOM] Setting up filesystem");
const FS = module.FS; // Emscripten's virtual filesystem API
// Create virtual directories (like mkdir -p)
// These exist only in memory until we mount IndexedDB
try {
FS.mkdir("/home");
FS.mkdir("/home/web_user");
FS.mkdir("/home/web_user/.config");
FS.mkdir("/home/web_user/.config/chocolate-doom");
} catch {
// Directories may already exist from previous run
}
// Write WAD file to virtual filesystem
// This makes the game data available to C code
FS.writeFile("/doom1.wad", wadData!);
// Mount IndexedDB as a real filesystem backend
// This persists data between browser sessions
FS.mount(
FS.filesystems.IDBFS, // IndexedDB filesystem driver
{}, // No special options
"/home/web_user/.config/chocolate-doom" // Mount point
);
// Sync from IndexedDB to memory (true = load)
FS.syncfs(true, (err) => {
if (!err) console.log("[DOOM] Saves loaded from IndexedDB");
});
}
],
System Call Translation in Action
When DOOM calls
fopen("/home/web_user/.config/chocolate-doom/save1.dsg", "w")
:
- C Code: Standard fopen system call
- Emscripten: Intercepts via its libc implementation
- Virtual FS: Creates file in JavaScript memory
- IndexedDB: Persists to browser storage on sync
- Result: C code thinks it wrote to disk, but it’s all in the browser!
This is the magic of Emscripten—your C code doesn’t need to know it’s running in a browser.
Step 11: Handle Runtime Events
Configure callbacks for initialization and errors:
postRun: [
() => {
doomLoaded = true;
setState({
status: "running",
statusMessage: "DOOM is running",
error: null
});
// Focus canvas for keyboard input
canvasRef.current?.focus();
// Save games periodically
const FS = module.FS;
setInterval(() => {
FS.syncfs(false, (err) => {
if (!err) console.log("[DOOM] Saves persisted");
});
}, 30000); // Every 30 seconds
}
],
print: (text: string) => console.log("[DOOM]", text),
printErr: (text: string) => console.error("[DOOM]", text),
setStatus: (text: string) => {
if (text) setState(s => ({ ...s, statusMessage: text }));
},
locateFile: (path: string) => {
if (path.endsWith(".wasm")) return wasmPath;
return path;
},
TOTAL_MEMORY: 134217728, // 128MB
ALLOW_MEMORY_GROWTH: true,
FS: {} as EmscriptenFileSystem,
callMain: (() => {}) as any,
pauseMainLoop: (() => {}) as any,
resumeMainLoop: (() => {}) as any,
onRuntimeInitialized: () => {
console.log("[DOOM] Runtime initialized");
},
onAbort: (what: string) => {
setState({
status: "error",
error: `Game crashed: ${what}`,
statusMessage: "Error"
});
}
};
Step 12: Load and Execute
Finally, load the JavaScript module and start the game:
// Set module globally (required by Emscripten)
window.Module = module;
// Load the Emscripten JavaScript
const script = document.createElement("script");
script.src = jsPath;
await new Promise<void>((resolve, reject) => {
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load game engine"));
document.body.appendChild(script);
});
} catch (err) {
setState({
status: "error",
error: err instanceof Error ? err.message : "Unknown error",
statusMessage: "Failed to load"
});
}
};
Step 13: Create the UI
Build a retro-styled interface with proper states:
// Render idle state
if (state.status === "idle") {
return (
<div className="flex flex-col items-center justify-center h-full p-4">
<h2 className="text-xl mb-4">DOOM - Chocolate Doom Port</h2>
<p className="mb-2">
{doomLoaded
? "DOOM can only run once per session."
: "Ready to start DOOM engine."}
</p>
{!doomLoaded && (
<p className="text-sm text-gray-600 mb-4">
{isCached === true
? "Game data is cached locally"
: isCached === false
? "Will download game data (4MB) from ibiblio.org"
: "Downloads game data from ibiblio.org"}
</p>
)}
<button
onClick={doomLoaded ? () => window.location.reload() : loadGame}
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
{doomLoaded ? "Refresh Page" : "Start DOOM"}
</button>
</div>
);
}
// Render game canvas
return (
<div className="flex flex-col h-full bg-black">
<div className="bg-gray-800 text-white px-2 py-1 text-sm">
{state.statusMessage}
{state.error && ` - Error: ${state.error}`}
</div>
<div className="flex-1 flex items-center justify-center">
<canvas
ref={canvasRef}
width={width}
height={height}
className="block focus:outline-none"
style={{ imageRendering: "pixelated" }}
tabIndex={0}
onContextMenu={(e) => e.preventDefault()}
/>
</div>
</div>
);
};
export default ChocolateDoom;
Step 14: Create a Custom Hook
For easy integration, create a hook that manages the component lifecycle:
// use-doom.tsx
import React, { useCallback } from "react";
import ChocolateDoom from "./doom";
export function useDoom() {
const [isOpen, setIsOpen] = React.useState(false);
const openDoom = useCallback(() => {
setIsOpen(true);
}, []);
const closeDoom = useCallback(() => {
setIsOpen(false);
}, []);
const DoomComponent = isOpen ? (
<div className="fixed inset-0 z-50 bg-black">
<button
onClick={closeDoom}
className="absolute top-4 right-4 text-white z-10"
>
Close
</button>
<ChocolateDoom />
</div>
) : null;
return { openDoom, closeDoom, DoomComponent };
}
Deployment
To deploy your WebAssembly DOOM:
- Build for production: Copy the
.wasm
and.js
files to your public directory - Configure CORS: If hosting WAD files separately, ensure proper CORS headers
- Use HTTPS: Required for Service Workers and Cache API
- Set headers: Serve
.wasm
files withContent-Type: application/wasm
Performance Tips
- Initial Memory: Start with 128MB to reduce load time
- Optimization: Use
-O2
or-O3
for better performance - Audio: Provide mute option as Web Audio can be CPU intensive
- Resolution: 640x400 provides authentic experience with good performance
Troubleshooting
Loading Issues
- Check browser console for CORS errors
- Verify all paths are correct
- Ensure WAD file is accessible
- Try clearing browser cache
Performance
- Reduce resolution if running slowly
- Disable sound with
-nosound
flag - Check browser’s hardware acceleration
- Close other tabs to free memory
Controls
- Click canvas to capture keyboard
- Arrow keys: move
- Ctrl: fire
- Space: open doors
- Shift: run
Key Takeaways for Developers
When working with WebAssembly in the future, remember these patterns:
The Translation Mindset
- Think in Bridges: Your C code doesn’t know about browsers. Emscripten builds bridges.
- System Calls Matter: Every
fopen()
,malloc()
, orprintf()
gets translated to a browser equivalent. - Loops Need Love: Infinite loops must yield to the browser with
emscripten_set_main_loop()
.
The Integration Pattern
C Code → LLVM IR → WebAssembly + JS Glue → Your Web App
Each arrow represents a transformation where you can apply optimizations and configurations.
Common Gotchas and Solutions
C Expects | Browser Reality | Solution |
---|---|---|
Infinite game loop | Single-threaded JS | Use emscripten_set_main_loop() |
Direct file access | Sandboxed environment | Virtual filesystem + IndexedDB |
Hardware interrupts | Event queue | SDL event emulation |
Memory pointers | Linear memory space | Emscripten manages heap |
The Developer Workflow
- Compile First, Integrate Later: Get your C code compiling to WASM before worrying about React
- Test in Isolation: Use a simple HTML file before adding framework complexity
- Debug with Console: Emscripten’s
printf
goes toconsole.log
- Profile Memory: WASM memory grows but doesn’t shrink—plan accordingly
Conclusion
You’ve just bridged a 30-year gap between DOS gaming and modern web browsers. The key insight? WebAssembly isn’t magic—it’s a well-designed translation layer that maps old assumptions to new realities.
This same approach works for any C/C++ codebase:
- Scientific computing libraries
- Image processing tools
- Game engines
- Audio/video codecs
The browser has become a universal runtime, and WebAssembly is your bridge to it. Now go forth and port that legacy code!