Loading…
gathering yarn balls…
Cat Game Research
Frontier
Dev-log transmissions from the expedition

The Browser Is Not a Console: Making Snes9x Run at 60 FPS in WebAssembly

Porting a native emulator to the web is not a recompile. We replaced the external snes9x-wasm wrapper with a project-owned Emscripten build, fixed an audio buffer bug that doubled latency, and measured LTO and SIMD so we know which levers actually matter.

The web is a terrible console. It has no real-time kernel, no predictable audio clock, and a JavaScript event loop that stalls whenever the browser decides to do something else. Yet we wanted the same Snes9x core we run natively to run in a browser tab at a stable 60 FPS as a baseline. That meant rethinking the wrapper, not just recompiling the core. We built a project-owned Emscripten pipeline, fixed a JS audio bug that doubled the audio buffer and mixed the channels, and measured every compile flag we could. The result is a 2.2 MB snes9x.wasm that runs Super Mario World at roughly 0.92 ms per frame in Node and serves only our own homebrew ROMs on the games site.

A Super Nintendo Entertainment System controller
The SNES controller is a low-latency input device. The browser is not. Bridging that gap is the real work of a WebAssembly emulator. Photo by Evan-Amos, public domain.

Why the old wrapper had to go

Our original web build cloned an external external/snes9x-wasm wrapper repository, which in turn cloned Snes9x and built it with its own s9x_shim.cpp. It worked, but it was fragile: two source lists, two shim files, and a build that could only happen in one specific environment. More importantly, it hid the audio problem. The native snes9x-devkit was fast, but the web page felt sluggish. We assumed the WASM core was slow. It was not.

The native devkit replaced the old Snes9x 1.63 AppImage for a reason. The AppImage bundled GTK/SDL, relied on RPATH, and could not run headless for deterministic capture. It also inherited a unix-port behavior we wanted to avoid: issue #530 showed that with SoundSync enabled, PulseAudio could report a fragment open even when nothing was playing, so the emulator could not throttle and ran too fast. On the web, the equivalent trap is the AudioContext. Browsers do not guarantee a 32 kHz clock like the S-DSP. They give you a 44.1 kHz or 48 kHz callback and expect you to fill it. If you miscalculate the buffer, you get exactly the same symptom: drifting, oversized buffers and mixed channels.

The self-hosted build

scripts/lib/sn/sn-build-wasm.sh is now the canonical build. It compiles the same upstream Snes9x sources from external/snes9x-src together with our vendored sn/shared/snes_emu_s9x_shim.cpp. The output is build/<preset>/wasm/snes9x.js and snes9x.wasm. The build uses the same snes9x_core_sources.cmake list as the native build, so the two cannot drift.

Project-owned Emscripten build
emcc -O3 -std=c++17 -fno-exceptions -fno-rtti \
     -s MODULARIZE=1 -s EXPORT_NAME=createSnes9xModule \
     -s INITIAL_MEMORY=64MB \
     -s ENVIRONMENT=web,node \
     -flto                           # when WASM_LTO=1 (default) \
     -o snes9x.js                    # plus the core source list

The build is incremental: it writes a build-args fingerprint to build/<preset>/wasm/build_args.txt and recompiles only sources newer than their object files unless the flags or the script itself changes. That matters because a full LTO rebuild of 52 core files plus the shim takes time, but an incremental build is fast enough to run during site generation.

70 KB
snes9x.js
2.2 MB
snes9x.wasm
52
Core Source Files
~0.92
ms/frame SMW (Node)

The audio buffer bug

The real performance win came from the JavaScript side. The old snes-emu.js created an AudioBuffer with a length equal to the interleaved stereo sample count:

Old wrapper pseudo-code: the buffer-length bug
// samples is Int16Array of interleaved stereo (LRLR...)
const buffer = audioContext.createBuffer(
  2,                    // 2 channels
  samples.length,       // BUG: this is 2x the frame count
  audioContext.sampleRate
);
// result: 2x-long buffers, channels mixed or silent

The fix is to deinterleave left and right into separate Float32Array views and create a two-channel buffer whose length is half the interleaved count. The new code uses frames = samples.length >> 1, builds Float32Array channels from the Int16Array sample data, and calls copyToChannel to fill the buffer. It also creates the AudioContext at the Snes9x output rate of 44.1 kHz and calls s9xwasm_setup(sampleRate, 0, 0) to set DSP_INTERPOLATION_NONE, reducing APU work on every sample.

Fixed deinterleaving and scheduling
const frames = samples.length >> 1;
const ch0 = new Float32Array(frames);
const ch1 = new Float32Array(frames);
for (let i = 0, j = 0; i < samples.length; ) {
  ch0[j] = samples[i++] / 32768.0;
  ch1[j] = samples[i++] / 32768.0;
  j++;
}
const buffer = audioCtx.createBuffer(2, frames, sampleRate);
buffer.copyToChannel(ch0, 0);
buffer.copyToChannel(ch1, 1);

// Cap scheduling queue to prevent audio-latency bloat
const MAX_AUDIO_LATENCY = 0.2;  // seconds
if (nextStartTime > now + MAX_AUDIO_LATENCY) {
  nextStartTime = now;
}

We also added s9xwasm_skip_render to the C API and skip PPU output on catch-up frames. If the JS loop is more than one frame behind, it runs the extra emulated frames without uploading them to the canvas, then renders only the last one. That reduces GPU upload work without affecting the emulated machine state.

LTO and SIMD: measuring the levers

Emscripten has two obvious compile-level levers for speed: LTO and SIMD. We tested both. LTO is on by default because it is a clear win. A 600-frame Super Mario World run in Node went from about 1.0 ms/frame without LTO to about 0.92 ms/frame with LTO. The binary size is unchanged enough to ignore. SIMD (-msimd128 -msse2) was a different story: it added about 30 KB to the .wasm and did not meaningfully improve the SNES9x core for this source tree. The Snes9x core is not written with vectorized inner loops that Emscripten can widen cleanly, so the extra instructions mostly bloat the binary. SIMD is off by default.

ConfigurationHello WorldSuper Mario WorldSize delta
LTO on, SIMD off (default)~0.69 ms/frame~0.92 ms/framebaseline
LTO on, SIMD on~0.68 ms/frame~0.91 ms/frame+30 KB

We did not measure a no-LTO build for this post; the table only shows the two builds we actually ran. The difference between LTO+SIMD and LTO alone is inside the noise, while the size increase is not, which is why SIMD stays off by default.

These are Node benchmarks, not browser benchmarks, but they are enough to rule SIMD out as a default. The next real gains will come from architecture, not compiler flags.

What we ship (and what we do not)

The games site (games.catgameresearch.net/snes/) only stages homebrew ROMs from data/sn/roms/snes/ that have a matching sn/roms/<id>/<id>.rom.toml. The oem and test_roms directories are excluded, so Super Mario World and other retail ROMs are never published. The wasm core itself does not contain any Nintendo assets. It is a clean recompile of the open-source Snes9x core plus our own shim.

Copyright / IP guardrail
The public games site ships only original homebrew ROMs. Retail ROMs in data/sn/roms/snes/oem/ are retained for local reverse-engineering work and are never published. The WASM core is built from the open-source Snes9x source and our own C++ shim. No copyrighted code, samples, or ROM data are embedded.

Architecture: why the browser is not a console

A console has a frame clock. The SNES produces 60.09881 frames per second, and the host has to pace the emulator to that clock. In a browser, the display refresh rate is usually 60 Hz, not 60.09881 Hz, and the audio callback runs on yet another clock. Snes9x solves this on the desktop with Dynamic Rate Control and configurable input rates. On the web we do not have that luxury yet. The current JS loop runs a batch of frames, schedules audio, and lets the browser present when it is ready. That works for 60 FPS but is sensitive to garbage collection, layout thrashing, and background tabs.

WASM Coresnes9x.wasmJS Frontenddeinterleave, cap latencyAudioContext44100 Hz, 2 channelsInt16 LRLRFloat32 LRFUTURE: Web Worker + AudioWorkletWASM core in worker, audio in AudioWorklet, SharedArrayBuffer ring bufferEmulation threadSharedArrayBufferAudioWorklet
Figure 1 — The current pipeline (top) runs the WASM core on the main thread and schedules audio into an AudioContext. The target architecture (bottom) moves emulation to a Web Worker and audio to an AudioWorklet with a shared ring buffer.

Roadmap: Worker, AudioWorklet, and the real 60 FPS problem

The current build is fast enough on a good machine. The remaining latency is not the emulator; it is the browser's main thread. The next step is the standard pattern used by emoinua, PCem-WebAssembly, flycast-wasm, and other serious web emulators:

Web Worker
Move the WASM core off the main thread. The main thread only handles input and canvas presentation.
AudioWorklet
Run the audio callback on a dedicated low-latency thread, not the main JS event loop.
SharedArrayBuffer
Use a lock-free ring buffer between worker and AudioWorklet; requires cross-origin isolation headers.
Dynamic rate control
Match the emulator's 60.09881 Hz to the display's actual refresh rate to avoid drift.
CapabilityStatusNotes
Project-owned WASM buildimplementedsn-build-wasm.sh builds snes9x.js + snes9x.wasm
Audio buffer deinterleave fiximplemented44.1 kHzAudioContext, MAX_AUDIO_LATENCY=0.2s
LTO defaultimplemented~0.92 ms/frame on SMW in Node
SIMDroadmapOff by default; +30 KB, no meaningful gain
Homebrew-only ROM stagingimplementedsn-wasm.sh skips oem and test_roms
Web Worker + AudioWorkletroadmapSharedArrayBuffer ring buffer architecture
Dynamic rate controlroadmapMatch 60.09881 Hz to display refresh

"The emulator is not the bottleneck. The browser is the console. Until we treat it like one — with a Worker for the machine and an AudioWorklet for the sound — we are only ever 60 FPS on a good day."

Cat Game Research · WASM Emu Notes · August 2026

Cross-links

References

← All transmissions