Running Animal Crossing (GameCube) in a browser
A port, not an emulator. Here is how the GameCube original ended up playable on a web page, and the bugs we hit getting there.
Port, not emulator
Most "play a console game in your browser" demos boot an emulator: a virtual GameCube that runs the original disc. We took a different route. The game's source has been recovered by a community decompilation, and a separate project ported that source to run as a normal PC program. We compile that PC port to WebAssembly with Emscripten and swap the desktop pieces (window, input, audio, file access) for browser ones.
The payoff is that there is no emulated console in the middle. The actual game logic runs as WebAssembly, which is lighter and faster than simulating the hardware. The cost is that you inherit every quirk of twenty-year-old console code, and the browser is a much stricter place to run it than a GameCube ever was.
One thing lined up nicely. The original engine stores pointers in 32-bit fields in a few places, so the whole project builds as a 32-bit program. WebAssembly's standard target, wasm32, is also 32-bit, so that assumption held for free. Almost nothing else was that easy.
WebAssembly is strict where the GameCube was forgiving
The GameCube's PowerPC toolchain was relaxed about undefined behavior. If a function did something technically illegal, the hardware usually did something reasonable anyway, and the game shipped. WebAssembly does not play along. The same code, compiled to wasm, turns those soft spots into hard traps that take the whole page down. A lot of the early work was hunting these down one at a time.
The first class was function-pointer mismatches. The engine is full of callback tables where a slot expects one signature and gets a function with a slightly different one. PowerPC does not check; WebAssembly checks every indirect call and traps on a mismatch. One example froze the game on the very first scene transition: a wipe-effect color function declared as taking no arguments lived in a table slot that called it with two, so the screen-wipe between the train cutscene and the town trapped mid-fade and hung the loop. We built a generator that emits typed wrapper functions for these slots (185 wrappers across 79 files) plus hand-fixes for the stragglers.
The second class was nastier. When a function contains undefined behavior, the optimizer is allowed to assume that code can never run, and it deletes the entire function, replacing it with a single trap instruction. The game looks fine until you touch that feature, then the tab dies. Three real ones:
- Talking to certain villagers hard-crashed the game. The cause was an
angle macro that computed
(s16)(180 * 182.04). That value does not fit in a 16-bit integer, the float-to-int conversion is undefined, and the optimizer deleted the entire thought-bubble effect function down to a trap. On the GameCube the conversion silently wrapped to the right number. The fix routes the math through a wide integer first so the narrowing is well defined. - The audio sequence interpreter could trap because one opcode handler
fell off the end of a function that was supposed to return a value. On
PowerPC the previous call happened to leave the right value in a
register, so it "worked." In wasm that is undefined behavior, the
function became a trap, and any sound sequence using that opcode killed
the audio engine. The fix is a one-line
return. - Cutscenes never advanced because a setup routine was missing its return statement, so it handed back garbage. The game read that garbage as "this NPC was not born yet" and re-spawned every NPC every single frame, which reset their animations before they could play. Restoring the return meant one Rover spawns, his animation runs, and the intro actually progresses.
All three are byte-for-byte identical to the upstream decompilation. They were never bugs on real hardware. They only surface because wasm refuses to paper over them. We ended up sweeping the compiled output for functions the optimizer had quietly turned into traps, which is a strange but effective way to find latent bugs.
This also raised the stakes on every crash. The native PC port can catch a fault and jump back to a safe point. WebAssembly has no equivalent, so a trap is terminal: the page reloads and you lose your spot. That is why we chase down each piece of undefined behavior instead of catching it, and why there is a crash dialog that captures the symbolicated stack so a report is actually useful.
The iframe has its own JavaScript universe
The game runs inside an iframe, separate from the page around it. That
boundary caused a bug that took a while to believe. When you drag a disc
image onto the page, or when your cloud save downloads, the bytes are
created by the outer page's JavaScript and handed to the game inside the
iframe. Both sides have a Uint8Array type, but they are not
the same type. Each frame has its own copy, with its own identity.
So the game's own check, "is this really a byte array," returned false for a byte array that was obviously a byte array, just one built by the neighbor. Worse, the filesystem layer does identity checks against its own memory and would intermittently see the foreign buffer as undefined, throwing a confusing "cannot read buffer" error. The symptom: your first drag-drop of a ROM hung on the loading screen until you refreshed, and every cloud-save restore failed silently even though the download returned the correct bytes. The fix is to copy the incoming bytes into a fresh array that belongs to the iframe. It costs one memcpy of a 27 MB file, which is fine.
Saving a town to the cloud, deterministically
Your disc image never leaves your browser, but saves are the part we put online so a town can follow you between devices. Saves live in the game's in-memory filesystem, which we persist to the browser's IndexedDB and pull back on the next visit. Getting that to round-trip across reloads meant mounting the save folder before the game starts and holding boot until the old contents finish loading in.
Then a stranger bug: the cloud kept claiming your local save and the cloud save had "diverged" and showing a conflict prompt, even when nothing had changed. Same files, same total size, different fingerprint. The cause was ordering. We hash the bundle of save files to decide if two saves match, but the filesystem returned the files in whatever order it rebuilt them from IndexedDB, and that order is not guaranteed to be stable. Two exports of the identical save in different orders produced different bytes, different hashes, and a false conflict. Sorting the files before hashing fixed it.
Audio fought us the whole way
Sound was the longest-running headache. Browsers will not start audio until you interact with the page, which is why there is a click-to-start screen, and the audio context still likes to drift to suspended, so the game re-wakes it on every tap and keypress. That alone fixed an early complaint where sound only kicked in 30 to 60 seconds after loading.
Then the timing. An early version throttled audio generation so it only ran on one frame in eight, producing about a tenth of the samples the output needed, which came out pitched and garbled. Removing that throttle fixed the pitch but exposed the opposite problem: latency. On iPhone, players reported sounds arriving almost a second late. The browser's audio path on iOS adds a few hundred milliseconds of its own, and our buffer was targeting another half second on top. We kept the large buffer because it survives the stutter while the engine warms up at boot, but dropped the steady-state target so normal play sits around 150 milliseconds instead of over 500. Slow frames that used to clip a sound effect now get a catch-up pass that fills the gap.
Making it run at full speed
For a while the game crawled at well under one frame per second during busy scenes. The culprit was not the game at all: it was logging. Every frame printed dozens of diagnostic lines, each one rebuilding part of the page, and the cost grew with the size of the log. A few thousand lines in, each print took milliseconds, and dozens of those per frame collapsed the frame rate.
The fix was unglamorous and effective. Per-frame chatter went behind a separate flag that is off by default, the log writes to an in-memory buffer instead of the page, and the audio pipeline skips its work entirely when the buffer is already full. That moved a heavy scene from roughly 0.4 frames per second to 50 to 100, with the frame pacer capping at the console's 60 Hz. Most of the perf work on this project was finding accidental per-frame costs like that one, not optimizing the game.
Phones run out of GPU memory
Desktop got every dial turned up: a large backing buffer, anti-aliasing, and optional HD texture packs. On phones, those same defaults caused the tab to reload over and over with no error in the logs. The reason it left no trace is that running out of GPU memory kills the tab before any crash handler can write anything down. The browser would drop the graphics context, our shell would reload to recover, the fresh boot would run out of memory again, and around it went.
We confirmed the base game itself is stable on mobile by running it under an emulated phone with a throttled CPU and memory-pressure pings for several minutes: the memory stayed flat and the graphics context never dropped. The growth was coming from HD textures, which live in GPU memory that no JavaScript API can even measure. So mobile now clamps the resolution, turns off anti-aliasing, skips texture preloading, and disables the HD pack entirely, while desktop keeps all of it.
The HD packs had their own twist. They ship as compressed textures in the format the Dolphin emulator uses, and the browser's graphics API starts with the compressed-texture features switched off. Desktop OpenGL hands those out automatically, so the desktop build worked, but on the web all 17,427 textures in a pack failed to load until we explicitly turned the right extensions on.
No bypasses
The tempting way to bring up a port like this is to paper over stuck states: wait some frames, then force the game to the next step. We banned that. Early on the project had a pile of skip flags doing exactly this, and they hid real bugs while making every later investigation harder. We deleted the whole mechanism, a few hundred lines of it, and adopted one rule: when a state stalls, find what the real game does to advance it and make that happen.
That rule is why the intro cutscene works for real now instead of being skipped past. The reason it stalled was the missing-return bug above, which re-spawned every NPC every frame. A timeout would have hidden that forever. Finding it meant the train arrival, Rover's dialog, and Tom Nook walking you to your house all play through the original code path, the way the GameCube ran them.
Credit and caveats
None of this exists without the teams behind the decompilation and the PC port it builds on. This is a fan project. It is not affiliated with Nintendo, and it ships no game data: you bring your own copy, and it stays in your browser. If you want to follow along or help, the Discord is the place.
New here? Read the how to play guide.