Porting an SDL2 Game to the Browser
This page answers one task: you have a game written in C or C++ on SDL2 — a classic engine, a game jam entry, a retro port — that builds natively, and you want it running in a browser tab with sound, input and saves working, without rewriting it.
Prerequisites
- [ ] The game’s source, building natively with SDL2 (and possibly SDL2_image, SDL2_mixer, SDL2_ttf).
- [ ] The Emscripten SDK installed and activated.
- [ ] The game’s assets and an idea of their total size.
What changes and what does not
Emscripten provides ports of SDL2 and its companion libraries that map SDL’s APIs onto the web: windows become a <canvas>, rendering goes through WebGL
(SDL’s renderer API or OpenGL ES 2/3 via Emscripten’s GL emulation), input comes from browser events, audio uses Web Audio, and timers map to browser timers.
Game logic, rendering code and most SDL calls compile unchanged.
The big difference is control flow. Native games usually own the main loop: while (running) { poll events; update; render; }. A browser tab cannot be
blocked like that — the page would freeze and never draw. The loop must become a callback that the browser calls once per frame. That is the one structural
change every SDL2 port needs; the rest is configuration and asset packaging.
Step 1 — restructure the main loop
Move the loop body into a function and register it:
#include <SDL2/SDL.h>
#ifdef __EMSCRIPTEN__
#include <emscripten.h>
#endif
static bool running = true;
static void frame(void) {
SDL_Event e;
while (SDL_PollEvent(&e)) { if (e.type == SDL_QUIT) running = false; handle_event(&e); }
update(1.0 / 60.0);
render();
#ifdef __EMSCRIPTEN__
if (!running) emscripten_cancel_main_loop();
#endif
}
int main(int argc, char** argv) {
init_game();
#ifdef __EMSCRIPTEN__
emscripten_set_main_loop(frame, 0, 1); // 0 = use requestAnimationFrame; 1 = simulate infinite loop
#else
while (running) { frame(); SDL_Delay(1); }
#endif
shutdown_game();
return 0;
}
Passing 0 as the frame rate uses requestAnimationFrame, matching the display’s refresh rate. Use a fixed-timestep accumulator inside frame if game
logic assumes a fixed rate. Any other blocking loops — loading screens that spin waiting for something, SDL_Delay loops — must be converted similarly or
handled with ASYNCIFY (Step 5).
Step 2 — build with the SDL2 ports
emcc src/*.c -O2 \
-sUSE_SDL=2 -sUSE_SDL_IMAGE=2 -sSDL2_IMAGE_FORMATS='["png"]' -sUSE_SDL_MIXER=2 \
-sALLOW_MEMORY_GROWTH=1 \
--preload-file assets@/assets \
-o dist/index.html
The -sUSE_SDL=2 family of flags downloads and builds the ports on first use and links them. Output to .html gives a ready-made shell page with a canvas;
use --shell-file for a custom page. --preload-file assets@/assets packages the asset directory into a .data file that loads before main runs and is
mounted in Emscripten’s virtual file system at /assets, so fopen("/assets/level1.map") works unchanged.
Step 3 — fix input and audio differences
Keyboard and mouse events arrive through SDL as usual, but the canvas must have focus — click to focus, or set tabindex and focus it. Browsers reserve some
keys (Ctrl+W, F5, F11); avoid them for gameplay. Pointer lock (for mouse-look) and fullscreen need a user gesture, which SDL handles when requested in
response to input events. Audio contexts start suspended until the user interacts with the page, so the first sounds may not play; show a “click to start”
screen, which also provides the focus and gesture the game needs.
Step 4 — persist saves
Writes to Emscripten’s default in-memory file system disappear on reload. Mount IDBFS (IndexedDB-backed) for the save directory and sync it:
EM_ASM(
FS.mkdir('/saves');
FS.mount(IDBFS, {}, '/saves');
FS.syncfs(true, function (err) { if (err) console.error(err); }); // load existing saves
);
// after writing a save file:
EM_ASM( FS.syncfs(false, function (err) { if (err) console.error(err); }); );
Link with -lidbfs.js. Sync after each save, not every frame. Newer Emscripten versions also offer WasmFS with an OPFS backend, which can be faster for large
save data.
Step 5 — decide between refactoring and ASYNCIFY
Some games have deeply nested blocking code — menus that run their own loops, cutscenes that wait — which is laborious to convert to callbacks. Emscripten’s
ASYNCIFY lets synchronous code pause and resume around emscripten_sleep calls, so a nested while loop can yield to the browser each iteration without
restructuring. It increases code size and slows execution (often 20–50% for instrumented functions), so use it selectively (-sASYNCIFY_ONLY= for the
functions that need it) or as a stopgap while converting loops properly. Threads (-pthread) work too but require cross-origin isolation headers on the
host, which many game portals cannot set.
Assets and loading
The .data package must download before main runs, so a large asset set means a long blank screen. Show progress (the default shell does; custom shells use
Module.setStatus and Module.monitorRunDependencies), compress assets, and consider splitting: preload only what the first level needs and fetch the rest at
runtime with emscripten_async_wget or the Fetch API. Serve .wasm and .data compressed with long-lived cache headers.
Graphics code that needs attention
Most SDL2 rendering works unchanged, but code that uses desktop OpenGL directly needs care. Emscripten maps OpenGL ES 2.0 to WebGL 1 and ES 3.0 to WebGL 2
(-sMAX_WEBGL_VERSION=2); desktop GL features such as immediate mode (glBegin/glEnd), fixed-function lighting or glPolygonMode are unavailable or only
partially emulated (-sLEGACY_GL_EMULATION covers a subset at a performance cost). Shaders written for desktop GLSL versions need porting to GLSL ES, with
explicit precision qualifiers. Texture formats differ too: some compressed formats are not available in WebGL, and non-power-of-two textures have
restrictions in WebGL 1. Check the console for WebGL errors during the first runs, and use the browser’s WebGL inspector or Spector.js to see which calls fail.
Games built entirely on SDL’s own 2D renderer avoid all of this.
Timing and frame pacing
Native games often measure time with SDL_GetTicks and sleep to cap frame rate. In the browser, requestAnimationFrame already paces frames to the display,
and sleeping is not available without ASYNCIFY. Remove frame-rate limiters, switch to a fixed-timestep update with interpolation, and use
SDL_GetPerformanceCounter (mapped to performance.now()) for delta time. Be aware that browsers pause requestAnimationFrame in background tabs; when the
player returns, cap the accumulated time so the game does not try to simulate minutes of missed updates in one frame.
Publishing to game portals
Portals such as itch.io host the output folder directly; upload a zip with index.html at the root. Many portals embed games in iframes, so make the canvas
fill its container, handle focus when the iframe is clicked, and avoid relying on cross-origin isolation for threads unless the portal supports the required
headers.
Expected output
The game runs in a browser at 60 fps with emscripten_set_main_loop; assets load from a 12 MB .data package with a progress bar; a click-to-start screen
unlocks audio and focuses the canvas; saves persist across reloads through IDBFS; and the build is published to a static host as index.html, .js, .wasm
and .data files.
Gotchas
- Keeping the blocking while loop. The tab freezes. Use
emscripten_set_main_loop. - Saving to MEMFS. Saves vanish on reload. Mount IDBFS and sync.
- Audio before a user gesture. Silent. Start behind a click.
- Huge preload packages. Long blank screens. Split and show progress.
- ASYNCIFY everywhere. Larger and slower. Limit it to functions that need it.
- Desktop-only OpenGL calls. WebGL is OpenGL ES. Port shaders and remove immediate mode.
Performance note
Converting a menu’s nested loop from ASYNCIFY to a callback state machine reduced the module from 4.1 MB to 3.2 MB and raised frame rate in a busy level from 48 to 60 fps.
Frequently Asked Questions
Does SDL2’s renderer API work, or do I need OpenGL? Both work; SDL’s 2D renderer is backed by WebGL in the port.
Can I use SDL3? Emscripten support for SDL3 has been developing; check current Emscripten ports before switching.
How do I handle window resizing? Listen for SDL window events and resize the canvas through CSS and SDL; set a fixed aspect ratio if the game needs one.
Will it work on mobile browsers? With touch input mapped (SDL provides touch and emulated mouse events) and modest memory use, yes.
Why does my GL code fail in the browser? WebGL maps to OpenGL ES; desktop GL features like immediate mode or desktop GLSL need porting or limited legacy emulation.
How should frame timing work after porting?
Drop frame-rate limiters, use a fixed timestep with requestAnimationFrame pacing, and cap catch-up time after background tabs.
Can the game run inside an iframe on a portal? Yes — make the canvas fill its container, handle focus on click, and avoid features that need headers the portal cannot set.
Related
- Handling input and audio in a Wasm game — input and audio details.
- Rendering with WebGL from a Wasm module — GL in the browser.
- Running Emscripten output in Node.js — headless testing.
- Using Emscripten settings to control memory — memory settings.
← Back to Graphics, Games & Simulation