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.
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.
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.
Gotchas
- Nothing renders and the tab hangs. The loop was not inverted, or
emscripten_set_main_loopwas called with a body that still loops internally. abort(OOM)on level load. Memory grew past what the browser allows. RaiseINITIAL_MEMORY, setMAXIMUM_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.
-pthreadbuilds need cross-origin isolation; without it, thread creation fails at runtime rather than at build time. mainreturning and everything stopping. Pass1forsimulate_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.
Related
- Rendering with WebGL from a Wasm module — the GPU side of a port.
- Migrating legacy C code to WebAssembly — the non-graphics half of the same problem.
- Using the Emscripten file system API — assets, persistence and mounts.
← Back to Graphics, Games & Simulation