Running DOOM in Browser with React using WebAssembly
Demo
Overview
This guide demonstrates how to compile Chocolate Doom (a C-based Doom engine) to WebAssembly and integrate it into a React application using Emscripten.
The process involves:
- Compiling legacy C code to WebAssembly using Emscripten.
- Bridging C system calls to browser APIs (Canvas, Web Audio, IndexedDB).
- Adapting the game loop for the browser’s event-driven architecture.
- Wrapping the WASM runtime in a React component.
Architecture: C vs. Browser
Legacy C programs like Doom were designed for direct hardware access, while browsers operate in a sandboxed, event-driven environment.
| Component | Legacy C Expectation | Browser Reality | Emscripten Solution |
|---|---|---|---|
| Execution | Infinite while(1) loop | Event loop (non-blocking) | emscripten_set_main_loop yields control |
| Graphics | Direct VGA buffer writes | HTML5 Canvas | SDL2 port writes to Canvas context |
| Storage | Direct file system access | Sandboxed storage | Virtual FS + IndexedDB persistence |
| Input | Hardware interrupts | DOM Events | SDL2 event mapping |
Emscripten acts as the translation layer:
Prerequisites
- Environment: Linux or WSL.
- Tools:
build-essential,git,python3,cmake. - Knowledge: Basic C/C++ compilation and React.
Install Emscripten SDK
First, install and activate the Emscripten SDK (emsdk):
sudo apt update && sudo apt install -y build-essential git python3 cmake
# Install Emscripten
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
# Activate environment variables
source ./emsdk_env.sh
Verifying installation:
emcc --version
Step 1: Clone Chocolate Doom
Clone the Chocolate Doom source code. We use this port because it maintains compatibility with the original DOS logic while using SDL, which Emscripten supports well.
mkdir -p ~/projects && cd ~/projects
git clone https://github.com/chocolate-doom/chocolate-doom.git
cd chocolate-doom
# Install build dependencies
sudo apt install -y automake autoconf libtool pkg-config
# Generate configuration scripts
./autogen.sh
Step 2: Patch the Game Loop
Browsers freeze if the main thread is blocked by an infinite loop. We must replace the standard while(1) loop with emscripten_set_main_loop(), which schedules frames via requestAnimationFrame.
Create 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 and Build
Use emconfigure and emmake to wrap the standard build tools. This ensures all compiler calls target WebAssembly (wasm32-unknown-emscripten) instead of the host architecture.
mkdir -p build-wasm && cd build-wasm
# Configure with optimization levels (-O2) and WASM 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"
# Build
emmake make -j$(nproc)
Key Compilation Flags:
-s USE_SDL=2: Links the JS implementation of SDL2.-s ALLOW_MEMORY_GROWTH=1: Allows the heap to resize at runtime.-s FORCE_FILESYSTEM=1: Includes the virtual filesystem support.-lidbfs.js: Links the IndexedDB filesystem backend for persistent data.
This produces:
src/chocolate-doom.wasm: The binary executable.src/chocolate-doom.js: The JavaScript loader and API.
Step 4: Asset Preparation
To play Doom, the engine requires a WAD file (Game Data).
- Copy the build artifacts to your project’s public directory (e.g.,
public/doom/). - Download the shareware WAD (
doom1.wad) to the same folder.
cp src/chocolate-doom.js ~/my-app/public/doom/
cp src/chocolate-doom.wasm ~/my-app/public/doom/
wget https://distro.ibiblio.org/slitaz/sources/packages/d/doom1.wad -O ~/my-app/public/doom/doom1.wad
Vite Configuration: Exclude the generated JS file from optimization to prevent parsing errors.
// vite.config.ts
export default defineConfig({
vite: {
optimizeDeps: {
exclude: ["chocolate-doom.js"],
},
assetsInclude: ["**/*.wasm"],
},
});
Step 5: React Integration
We need to define the TypeScript interfaces for the Emscripten module and build a React component to manage the lifecycle.
Module Interface
// 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 };
}
Component Implementation
The component handles:
- WAD Caching: Fetching and caching the 4MB game file using the Cache API.
- Module Initialization: Creating the global
Moduleobject required by Emscripten. - Filesystem Mounting: Mounting IndexedDB to persist save games.
// doom.tsx
import React, { useRef, useState, useEffect } from "react";
import type { EmscriptenModule, EmscriptenFileSystem } from "./doom-types";
// Singleton tracking
let doomLoaded = false;
interface ChocolateDoomProps {
wasmPath?: string;
jsPath?: string;
wadPath?: string;
}
const ChocolateDoom: React.FC<ChocolateDoomProps> = ({
wasmPath = "/doom/chocolate-doom.wasm",
jsPath = "/doom/chocolate-doom.js",
wadPath = "/doom/doom1.wad",
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [status, setStatus] = useState("Idle");
const [error, setError] = useState<string | null>(null);
const loadGame = async () => {
if (doomLoaded) return window.location.reload(); // Refresh required for restart
setStatus("Initializing...");
try {
// 1. Fetch WAD file (with Cache API support)
const cache = await caches.open('doom-wad-cache');
let response = await cache.match(wadPath);
if (!response) {
response = await fetch(wadPath);
cache.put(wadPath, response.clone());
}
const wadData = new Uint8Array(await response.arrayBuffer());
// 2. Configure Module
const module: EmscriptenModule = {
canvas: canvasRef.current,
arguments: ["-iwad", "/doom1.wad", "-window", "-nosound"], // Add args as needed
preRun: [() => {
// Setup Virtual Filesystem
const FS = module.FS;
try {
// Create user directories
FS.mkdir("/home/web_user/.config/chocolate-doom");
} catch(e) { /* ignore existing */ }
// Write game asset to VFS
FS.writeFile("/doom1.wad", wadData);
// Mount IndexedDB for persistence
FS.mount(FS.filesystems.IDBFS, {}, "/home/web_user/.config/chocolate-doom");
// Load existing saves
FS.syncfs(true, (err) => {
if (err) console.error("Failed to sync FS", err);
});
}],
postRun: [() => {
doomLoaded = true;
setStatus("Running");
// Auto-save loop (sync memory -> IndexedDB)
setInterval(() => {
module.FS.syncfs(false, () => console.log("Saves synced"));
}, 30000);
}],
print: (text) => console.log("[DOOM]", text),
printErr: (text) => console.error("[DOOM]", text),
locateFile: (path) => path.endsWith(".wasm") ? wasmPath : path,
TOTAL_MEMORY: 134217728,
ALLOW_MEMORY_GROWTH: true,
FS: {} as EmscriptenFileSystem,
callMain: () => {},
pauseMainLoop: () => {},
resumeMainLoop: () => {},
};
// 3. Inject Script
window.Module = module;
const script = document.createElement("script");
script.src = jsPath;
document.body.appendChild(script);
} catch (e: any) {
setError(e.message);
}
};
return (
<div className="flex flex-col items-center">
<div className="bg-gray-900 p-1 mb-2 text-white w-full text-center text-sm font-mono">
STATUS: {status} {error && `| ERROR: ${error}`}
</div>
{status === "Idle" && (
<button
onClick={loadGame}
className="px-6 py-2 bg-blue-600 text-white font-bold rounded"
>
Initialize Engine
</button>
)}
<canvas
ref={canvasRef}
onContextMenu={e => e.preventDefault()}
tabIndex={0}
className="bg-black focus:outline-none max-w-full"
/>
</div>
);
};
export default ChocolateDoom;
Troubleshooting
CORS Errors
If loading WAD files from external sources, you may encounter CORS blocks.
Solution: Host the WAD file in your own public/ directory or ensure the external server sends Access-Control-Allow-Origin: *.
SharedArrayBuffer
If you enable multithreading (pthreads), modern browsers require the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers.
Solution: Add these headers to your server config:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Performance
For best performance:
- Build with optimization
-O3. - Ensure the canvas is hardware accelerated.
- Use the browser’s “Performance” tab to profile the WASM execution.