Porting a C Game Loop to Emscripten

This guide answers one task: take a C or C++ program built around an infinite main loop and make it run in a browser, where a function that never returns freezes the page permanently.

Prerequisites

  • [ ] Emscripten 3.1.60 or later, activated in your shell.
  • [ ] A native build that already works, ideally with SDL2 or raw OpenGL ES.
  • [ ] The game’s assets in a directory you can preload or fetch.
  • [ ] A local server; file:// will not load the generated .wasm.

The problem in one sentence

A browser is single-threaded from the page’s perspective, and rendering only happens when your code returns control to the event loop. A native loop of the form below never returns, so the canvas never updates, input never arrives, and the tab becomes unresponsive until the browser offers to kill it.

// works natively, hangs a browser tab forever
while (running) {
    poll_input();
    update(dt);
    render();
    swap_buffers();
}

The fix is to invert the loop: give the browser a function representing one iteration and let it call you.

Inverting the loop Natively the program owns the loop and calls the platform. In a browser the platform owns the loop and calls one iteration of the program, returning control between frames so rendering and input can happen. native: you own the loop while (running) { … } never returns to the caller in a tab: nothing ever renders browser: the platform calls you emscripten_set_main_loop(frame, 0, 1) frame() returns after one iteration rendering and input happen in between Everything else in a port is mechanical. This inversion is the one structural change the browser genuinely requires.

Restructure with emscripten_set_main_loop

Extract the loop body into a function and hand it to Emscripten. Any state the body relied on being local to main has to move to a struct passed through the callback’s argument, or to file scope.

#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif

typedef struct { GameState *gs; double last; } LoopCtx;

static void frame(void *arg) {
    LoopCtx *ctx = (LoopCtx *)arg;
    double now = emscripten_get_now() / 1000.0;
    double dt = now - ctx->last;
    ctx->last = now;

    poll_input(ctx->gs);
    update(ctx->gs, dt);
    render(ctx->gs);
}

int main(void) {
    static LoopCtx ctx;
    ctx.gs = game_init();
    ctx.last = emscripten_get_now() / 1000.0;
#ifdef __EMSCRIPTEN__
    emscripten_set_main_loop_arg(frame, &ctx, 0, 1);   // 0 = use requestAnimationFrame
#else
    while (ctx.gs->running) frame(&ctx);
#endif
    return 0;
}

Passing 0 as the frame rate means requestAnimationFrame, which is what you want — it matches the display, pauses in background tabs, and avoids the drift of a fixed interval. The final 1 means “simulate an infinite loop”, which prevents Emscripten from running the code after main returns.

Note that main effectively does not return under this model, so any cleanup after the loop never runs. Move shutdown into an explicit function called from the browser, or accept that a tab close is your process exit — which it is anyway.

The alternative: Asyncify

If restructuring is impractical — deeply nested loops, a loop inside a library you cannot modify — Asyncify rewrites the module so that the code can yield to the browser from anywhere and resume later.

emcc game.c -O2 -sASYNCIFY -sASYNCIFY_STACK_SIZE=32768 -o game.js
while (running) {
    poll_input(); update(dt); render();
    emscripten_sleep(0);        // yields to the event loop, resumes here
}

The cost is real: Asyncify instruments the module to save and restore the call stack, which typically adds 30–100% to the binary size and a measurable runtime overhead on every call that can yield. Restrict it with ASYNCIFY_ONLY to the functions that actually need it, and treat it as a way to ship a port quickly rather than as the final architecture.

Assets: the virtual file system

Code that calls fopen needs files to exist. Emscripten provides a virtual file system with several population strategies, and the choice affects startup substantially.

# bundle files into the output — simple, but they all download before anything runs
emcc game.c --preload-file assets -o game.js

# or fetch on demand at runtime, which is what a large game needs
emcc game.c -sFORCE_FILESYSTEM=1 -o game.js

--preload-file produces a .data file alongside the module, downloaded in full before main runs. That is fine for tens of megabytes and unacceptable for hundreds. For larger games, fetch asset packs yourself and write them into the file system as they arrive, so the first level starts while the rest downloads.

Input, audio and the gesture requirement

SDL2 ports mostly work unchanged: Emscripten maps its event handling onto DOM events. Three things need attention regardless.

Audio cannot start without a user gesture. Browsers suspend the audio context until a click or key press, so a game that initialises audio at startup will be silent with no error. Resume the context from the first input event.

Pointer lock and fullscreen also require a gesture, and must be requested from within the event handler rather than from your game loop — a request made a frame later is rejected.

Keyboard handling defaults to capturing keys the browser needs. Decide explicitly whether your canvas swallows the Escape key, the function keys and the browser’s own shortcuts, because a game that traps Escape and cannot be exited is a support problem.

What has to happen before the first frame The module downloads and instantiates, assets are fetched into the virtual file system, and the first frame renders. Audio remains suspended until the player's first input, which happens after the game is already visible. fetch + compile module assets into the file system main() runs, loop starts first frame drawn audio stays suspended until the first click or key press Report each phase separately in the loading screen. A single bar that reaches 100% and then pauses for eight seconds reads as a hang.

Build flags that matter for a port

emcc $(SOURCES) \
  -O3 -flto \
  -sUSE_SDL=2 -sUSE_WEBGL2=1 -sFULL_ES3=1 \
  -sINITIAL_MEMORY=256MB -sALLOW_MEMORY_GROWTH=1 -sMAXIMUM_MEMORY=1GB \
  -sEXPORTED_RUNTIME_METHODS='["ccall"]' \
  -sASSERTIONS=0 \
  --preload-file assets \
  -o game.js

INITIAL_MEMORY set high avoids a burst of growth during level load, each instance of which copies the whole heap. MAXIMUM_MEMORY bounds the damage from a leak. FULL_ES3 enables the full OpenGL ES 3 emulation, which costs size but avoids a long tail of missing-function surprises in an existing engine.

During development, invert several of these: -O1 -g -sASSERTIONS=2 -sSAFE_HEAP=1 produces a much larger and slower build that reports out-of-bounds accesses with a stack trace instead of corrupting memory silently.

Expected output

A working port logs the loop starting and holds a steady frame time:

[wasm] module instantiated in 142 ms
[wasm] preloaded assets: 38.2 MB
[game] renderer: WebGL 2.0 (OpenGL ES 3.0 Chromium)
[game] main loop started at 60 Hz
[game] frame p50 8.4 ms  p95 11.2 ms

If frame time is fine but the game stutters, check whether the loop is doing several fixed steps per frame after a long pause — the clamp described in the topic overview matters here.

The loop has to be inverted A native game owns the loop. In a browser the host owns it, so the body becomes a callback the browser invokes once per frame and which must return promptly. native while(running) blocks forever — the browser never gets the thread back set_main_loop callback one frame one frame the browser renders in every gap Any blocking wait inside the body has the same effect as the original loop and freezes the tab. State that lived on the stack across iterations has to move somewhere that survives the return.

Gotchas

  • Nothing renders and the tab hangs. The loop was not inverted, or emscripten_set_main_loop was called with a body that still loops internally.
  • abort(OOM) on level load. Memory grew past what the browser allows. Raise INITIAL_MEMORY, set MAXIMUM_MEMORY, and reduce peak allocations.
  • Silent audio. The audio context is suspended; resume it from a gesture handler.
  • Assets missing at runtime. Paths in the virtual file system are absolute from its root and are case-sensitive even when your development machine was not.
  • Threads fail to start. -pthread builds need cross-origin isolation; without it, thread creation fails at runtime rather than at build time.
  • main returning and everything stopping. Pass 1 for simulate_infinite_loop, or keep the runtime alive explicitly.

Performance note

A mid-sized SDL2 game compiled at -O3 with FULL_ES3 produced a 4.2 MB module plus a 38 MB asset pack, instantiated in 142 ms, and ran at 8.4 ms per frame against 5.1 ms for the same code natively — roughly 60% of native speed, which is typical for a port that spends most of its time in the GPU driver anyway. Adding Asyncify to avoid restructuring the loop raised the module to 7.1 MB and the frame time to 11.6 ms, which is a clear argument for doing the restructuring properly.

Frequently Asked Questions

Can I keep my native build working from the same source? Yes, and you should. Guard the Emscripten-specific parts with #ifdef __EMSCRIPTEN__ and keep the native loop in the #else branch — debugging is far faster natively, and the two paths stay honest.

How do I save the player’s progress? Mount IDBFS and call FS.syncfs after writes, which persists the virtual file system to IndexedDB. Remember the sync is asynchronous; a save that is never synced is lost on reload.

Does this work for engines that own their own window? Usually yes, if they use SDL2 or GLFW, both of which Emscripten emulates. An engine that talks to the platform directly needs a browser backend written for it, which is a considerably larger project.

What about the window size and the canvas? Emscripten maps the SDL window onto a canvas element, but it does not follow CSS sizing on its own. Call emscripten_set_canvas_element_size when the layout changes and re-read the size in your resize handler, multiplying by devicePixelRatio so the game is not rendered at a quarter resolution on a high-density display.

Can I pause the game when the tab is hidden? requestAnimationFrame already stops firing in a hidden tab, so the loop pauses automatically. What you must handle is the resumption: the elapsed time since the last frame will be enormous, which is exactly why the timestep clamp exists. Listen for visibilitychange as well if you want to mute audio or drop a network connection while hidden.

← Back to Graphics, Games & Simulation