A technical guide to compiling Chocolate Doom to WebAssembly with Emscripten and integrating it into a React application.

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:

  1. Compiling legacy C code to WebAssembly using Emscripten.
  2. Bridging C system calls to browser APIs (Canvas, Web Audio, IndexedDB).
  3. Adapting the game loop for the browser’s event-driven architecture.
  4. 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.

ComponentLegacy C ExpectationBrowser RealityEmscripten Solution
ExecutionInfinite while(1) loopEvent loop (non-blocking)emscripten_set_main_loop yields control
GraphicsDirect VGA buffer writesHTML5 CanvasSDL2 port writes to Canvas context
StorageDirect file system accessSandboxed storageVirtual FS + IndexedDB persistence
InputHardware interruptsDOM EventsSDL2 event mapping

Emscripten acts as the translation layer:

SDL2 Layer

Emscripten Compiler

Chocolate Doom C Code

WebAssembly Module

JavaScript Glue Code

Browser APIs

Canvas API
Graphics Rendering

Web Audio API
Sound Output

Pointer Events
Mouse/Keyboard Input

IndexedDB
Save Games

SDL_Video

SDL_Audio

SDL_Events

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).

  1. Copy the build artifacts to your project’s public directory (e.g., public/doom/).
  2. 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:

  1. WAD Caching: Fetching and caching the 4MB game file using the Cache API.
  2. Module Initialization: Creating the global Module object required by Emscripten.
  3. 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:

  1. Build with optimization -O3.
  2. Ensure the canvas is hardware accelerated.
  3. Use the browser’s “Performance” tab to profile the WASM execution.

Resources