DEV Community

RobustTrueTry
RobustTrueTry

Posted on

The Blocking Main Loop That Kills Browser Game Ports

A 2004 Windows game now runs in a browser tab, and the impressive part is not the graphics. It is that someone untangled two decades of assumptions about how a machine runs a program. Ports like this fail in the same few places, and almost none of those failures are about raw speed.

By the end you will know how to restructure a legacy game loop for the event loop, keep simulation stable across display refresh rates, and make saves survive a page refresh.

The main loop is the first thing that breaks

A native game owns the process. It runs while (running) { update(); render(); } and never returns. In a browser that loop holds the main thread, the tab locks up, and the browser eventually offers to kill it. The only way to keep rendering is to hand control back to the browser every frame.

Emscripten's standard answer is a callback instead of a loop:

#include <emscripten.h>

static int g_frames_left = 300;

void frame(void) {
    if (g_frames_left-- <= 0) {
        emscripten_cancel_main_loop();
        return;
    }
    update();
    render();
}

int main(void) {
    emscripten_set_main_loop(frame, 0, 1);
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

The 0 means "follow the browser's vsync" rather than a fixed rate, and the trailing 1 tells Emscripten to simulate an infinite loop so main returning does not end the program. The real cost is elsewhere: your C stack unwinds and re-enters every frame, so any variable that must survive across frames has to live in static or heap storage rather than as a local.

There are two other ways off the main thread, and each buys something different.

Approach How it yields Tradeoff
emscripten_set_main_loop You restructure into a per-frame callback Smallest and fastest output, but all cross-frame state leaves the stack
ASYNCIFY The compiler rewrites your blocking loop so it can pause Keeps the original code shape, at the cost of a larger and slower binary
Web Worker with OffscreenCanvas The whole game runs off the main thread Real parallelism, but input, audio, and canvas setup all get harder

Decouple simulation from the display

Plenty of games from this era assume one update() per rendered frame. That assumption breaks twice in a browser: a 144 Hz monitor runs requestAnimationFrame more often than the game expects, and a backgrounded tab throttles it to nearly nothing. Tie physics to the frame and the game runs fast on some screens and in slow motion in others.

The fix is a fixed-timestep accumulator with interpolation:

const STEP = 1000 / 60; // simulation always runs at 60 Hz
let last = performance.now();
let acc = 0;

function frame(now) {
    acc += now - last;
    last = now;
    if (acc > 250) acc = 250; // cap the catch-up
    while (acc >= STEP) {
        game.update(STEP / 1000);
        acc -= STEP;
    }
    game.render(acc / STEP);
    requestAnimationFrame(frame);
}

requestAnimationFrame(frame);
Enter fullscreen mode Exit fullscreen mode

update() always sees the same dt, so physics and AI stay deterministic regardless of the display. render() receives the leftover fraction and interpolates between the previous and current state, which is what makes movement look smooth on a refresh rate the game was never designed for.

The 250 clamp is the part most ports leave out. Without it, a tab left in the background for an hour tries to simulate an hour of gameplay the instant it regains focus. Decide deliberately whether a hidden tab should pause the simulation outright or just catch up; the clamp keeps either choice survivable.

Load assets without blocking the thread

Legacy code calls fopen and expects bytes to be there. Emscripten satisfies that with MEMFS, an in-memory filesystem you populate at startup:

emcc game.o -o game.html \
    --preload-file assets@/assets \
    -s ALLOW_MEMORY_GROWTH=1
Enter fullscreen mode Exit fullscreen mode

The --preload-file flag packs the assets into a .data file fetched before main runs, so the C code sees a real directory tree. Avoid the temptation to fetch files lazily with synchronous XMLHttpRequest on the main thread; that path is deprecated in browsers and will fight you. If you need lazy loading, fetch asynchronously and write the bytes into MEMFS yourself, then signal the game through a callback.

Keep saves across browser sessions

An in-memory filesystem means every save vanishes on refresh. Emscripten ships IDBFS, a filesystem backed by IndexedDB, for exactly this. Mount it once during startup:

FS.mkdir("/save");
FS.mount(IDBFS, {}, "/save");
FS.syncfs(true, (err) => {
    if (err) console.error("idbfs restore failed:", err);
    else startGame();
});
Enter fullscreen mode Exit fullscreen mode

Writes go to the in-memory copy first, so you must flush them explicitly:

#include <emscripten.h>

void save_progress(void) {
    FILE *f = fopen("/save/progress.dat", "wb");
    if (!f) return;
    fwrite(&g_progress, sizeof(g_progress), 1, f);
    fclose(f);
    EM_ASM(
        FS.syncfs(false, function(err) {
            if (err) console.error("syncfs failed:", err);
        });
    );
}
Enter fullscreen mode Exit fullscreen mode

The true argument on startup pulls IndexedDB into memory; false on save pushes memory back to IndexedDB. Forgetting the second call is the single most common reason a browser port appears to lose progress.

What still breaks after all that

A few problems do not yield to the patterns above, and it helps to know them before you plan the work.

  • Audio needs a user gesture. Browsers suspend an AudioContext created on page load. Create or resume it from the first keypress or click, not during startup.
  • WebGL contexts get lost. When the OS reclaims the GPU, your context dies. Handle webglcontextlost, stop rendering, and rebuild your GL state on webglcontextrestored.
  • Keyboard input fights the page. Arrow keys and space scroll the window. Call preventDefault on the keys the game actually claims.
  • Memory budgets differ. A game sized for 2004 hardware can ask for more than a mobile browser wants to hand a single tab. Test on a low-end device early.

Key takeaways

  • A native while loop cannot survive the browser's event loop; restructure into a per-frame callback or let ASYNCIFY pause it for you.
  • Run simulation at a fixed timestep and interpolate rendering, so a 144 Hz screen and a throttled background tab both behave the same way.
  • Clamp the accumulator, or a backgrounded tab will try to simulate hours of gameplay in one burst.
  • Preload assets into MEMFS rather than reaching for synchronous requests on the main thread.
  • Mount IDBFS and call FS.syncfs on every save, or progress disappears the moment the page reloads.

Source

Need for Speed Underground 2 online in the browser is the project that prompted this piece. I added the porting failure modes, the working loop and persistence code, and the tradeoff comparison the project page does not cover.

Support this work

These write-ups are researched and published with no paywall, sponsor, or tracking. If one saved you an afternoon, a small tip keeps them coming.

USDT, USDC or USDD ยท TRC-20 (Tron)

TFTNsfyomKrnUutRjBTGVULp19ByW29KbY
Enter fullscreen mode Exit fullscreen mode

Top comments (0)