Learn how WebAssembly bridges C programs and web browsers by compiling DOOM to run in React. A practical guide to understanding WASM's translation magic.

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:

  1. Legacy Code Migration: Thousands of C/C++ applications can be brought to the web
  2. Performance: Near-native speed for computationally intensive tasks
  3. Code Reuse: No need to rewrite existing C/C++ codebases in JavaScript
  4. 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:

What Browsers Provide

Translation Layer

What C Code Expects

Memory mapping

Event emulation

Loop transformation

Virtual FS

Draws to

Reads from

Scheduled by

Stores in

Direct Memory Access

Hardware Interrupts

Infinite Loops

File System

Emscripten Runtime

WebAssembly Module

Canvas API

DOM Events

requestAnimationFrame

IndexedDB

How WebAssembly Works

Now let’s see how this translation happens in practice. Here’s how Chocolate Doom runs in your browser:

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

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:

Output Files

LLVM Pipeline

Clang Frontend

LLVM Optimizer

Emscripten Backend

Emscripten Backend

C/C++ Source

LLVM IR

Optimized LLVM IR

WebAssembly Binary

JavaScript Glue Code

.wasm file

.js file

The process works as follows:

  1. Clang Frontend: Parses C/C++ code and generates LLVM Intermediate Representation (IR)
  2. LLVM Optimizer: Applies optimizations at the IR level (same as native compilation)
  3. 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 needed
  • MODULARIZE=1: Creates a JavaScript module we can import
  • FORCE_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:

Browser APIs

WebAssembly Provides

Emscripten Runtime Layer

Your C Code

fopen/fwrite calls

printf calls

malloc calls

Virtual File System

Console Redirect

Memory Manager

Linear Memory
Shared ArrayBuffer

Function Calls
Import/Export

IndexedDB

console.log

Memory Growth

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:

  1. Store data in a JavaScript object that mimics a directory structure
  2. Optionally persist to IndexedDB for permanent storage
  3. 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"):

  1. C Code: Standard fopen system call
  2. Emscripten: Intercepts via its libc implementation
  3. Virtual FS: Creates file in JavaScript memory
  4. IndexedDB: Persists to browser storage on sync
  5. 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:

  1. Build for production: Copy the .wasm and .js files to your public directory
  2. Configure CORS: If hosting WAD files separately, ensure proper CORS headers
  3. Use HTTPS: Required for Service Workers and Cache API
  4. Set headers: Serve .wasm files with Content-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(), or printf() 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 ExpectsBrowser RealitySolution
Infinite game loopSingle-threaded JSUse emscripten_set_main_loop()
Direct file accessSandboxed environmentVirtual filesystem + IndexedDB
Hardware interruptsEvent queueSDL event emulation
Memory pointersLinear memory spaceEmscripten manages heap

The Developer Workflow

  1. Compile First, Integrate Later: Get your C code compiling to WASM before worrying about React
  2. Test in Isolation: Use a simple HTML file before adding framework complexity
  3. Debug with Console: Emscripten’s printf goes to console.log
  4. 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!

Resources