What This Is
Dolphin is the long-running open-source GameCube and Wii emulator. We ported it to run entirely in the browser using Emscripten and WebAssembly. You pick a game file, it boots in your tab. The game never leaves your machine.
This is an early-stage port. Performance is limited by the CPU backend (interpreted, not JIT-compiled) and the software renderer. But the groundwork is there: games boot, render, and accept input. On desktop Chrome it's usable for lighter titles. On iOS Safari it runs too, which was a surprise.
How It Works
The Dolphin C++ codebase compiles to WASM through Emscripten with a set of platform-specific patches:
| Component | Implementation |
|---|---|
| CPU | CachedInterpreter (no native JIT possible in WASM) |
| Video | Software Renderer with EGL/WebGL2 canvas output |
| Audio | Not yet implemented |
| Memory | malloc-backed arena (replaces shm_open/mmap) |
| Threading | Emscripten pthreads (SharedArrayBuffer + Web Workers) |
| Input | Web Bluetooth (NSW2 controllers) + on-screen touch |
| Game loading | Zero-copy lazy filesystem backed by browser File object |
All changes to the Dolphin source are guarded behind #ifdef __EMSCRIPTEN__. The fork is on GitHub.
Zero-Copy Game Loading
Game files can be huge. Some Wii disc images exceed 3 GB. Loading that into WASM memory would require the browser to allocate 3+ GB of heap, which crashes iOS Safari and pushes desktop browsers to their limits.
Instead, the file stays in the browser's native File object. We create a virtual file node in Emscripten's filesystem that intercepts every read call and pulls just the bytes needed directly from the original file using synchronous XHR on a blob URL slice. Dolphin reads in small chunks through its disc I/O layer, so it never notices the difference. Total WASM memory used for the game: zero.
// Virtual file node backed by browser File object
// Reads happen on demand via sync XHR on blob URL slices
var blob = file.slice(position, position + length);
var url = URL.createObjectURL(blob);
var xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain; charset=x-user-defined');
xhr.send();
URL.revokeObjectURL(url);
Chrome forbids setting responseType on synchronous XHR, so we use the x-user-defined charset trick to get raw bytes as a binary string. Safari allows responseType directly. Both paths produce the same result.
SharedArrayBuffer on iOS
Emscripten's pthreads require SharedArrayBuffer, which requires Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp headers. iOS Safari supports this, but only over HTTPS. Localhost HTTP won't work.
For local development we run a Python HTTPS server with a self-signed certificate. For production, Cloudflare Pages serves the headers automatically via a _headers file in the deploy directory.
Controller Support
Two input methods:
On-screen touch controls. A full GameCube layout: virtual analog stick with concentric ring grips, D-pad, A/B/X/Y buttons (X and Y use the actual bean shape from the real controller via CSS mask images), C-stick, L/R/Z triggers, and Start. Works on mobile and desktop.
Nintendo Switch 2 NSO GameCube controller via Web Bluetooth. We ported the BLE connection code from our Switch 2 controller project. The controller connects over Bluetooth in Chrome, completes the Nintendo proprietary pairing handshake, reads SPI flash calibration data, and feeds calibrated input directly into Dolphin's GCPad system through an exported C function bridge.
// C side (EmscriptenInput.cpp)
EMSCRIPTEN_KEEPALIVE
void dolphin_set_controller_state(int port, uint32_t buttons,
int16_t lx, int16_t ly, int16_t rx, int16_t ry,
uint8_t tl, uint8_t tr);
// JS side
Module._dolphin_set_controller_state(port,
state.buttons, state.calLeftX, state.calLeftY,
state.calRightX, state.calRightY,
state.calTriggerL, state.calTriggerR);
What Doesn't Work Yet
- Audio. No WebAudio integration yet. Games run silent.
- Hardware rendering. The OGL/WebGL2 backend deadlocks during init due to GL thread proxy overhead in Emscripten. The software renderer works but is CPU-bound.
- WASM JIT. We built the skeleton for a PPC-to-WASM two-tier JIT (translate PPC instructions to WASM bytecode, instantiate via
WebAssembly.instantiate(), call through the function table). The translator handles integer arithmetic, logic, shifts, loads, stores, and branches. It's not active yet. - Rumble. The BLE manager supports GameCube rumble packets, but the Dolphin SI subsystem's rumble command path doesn't fire on the WASM build. Under investigation.
- Performance. The CachedInterpreter is slow. Lighter GameCube titles are playable on desktop. Heavier games and most Wii titles run but at reduced speed.
Building It Yourself
The entire project is open source. You need the Emscripten SDK and CMake.
git clone --recursive https://github.com/bitaxislabs/DolphinWeb.git
cd DolphinWeb/dolphin
source ~/emsdk/emsdk_env.sh
emcmake cmake -B build-wasm -S . \
-DCMAKE_BUILD_TYPE=Release \
-DENABLE_HEADLESS=ON -DENABLE_NOGUI=ON \
-DENABLE_QT=OFF -DENABLE_TESTS=OFF \
-DUSE_DISCORD_PRESENCE=OFF -DWITH_OPTIM=OFF
cmake --build build-wasm -j$(nproc)
cp build-wasm/Binaries/dolphin-emu-nogui.{js,wasm,data} ../web/
Then serve the web/ directory with any HTTP server that sets the COOP/COEP headers. A server.py is included.
Try It
Acknowledgments
Dolphin is developed by the Dolphin Emulator Project and licensed under GPL-2.0-or-later. This port builds on their work. No games or copyrighted data are included or distributed.