Running Animal Crossing (GameCube) in a browser

A build log of how the GameCube original ended up playable on a web page, newest entries first. A port, not an emulator. The browser work started May 2026, on top of a community decompilation and PC port.

June 14

Dropping furniture outside — as the real thing

Drop a piece of furniture on the ground in town and the original game shows a generic leaf icon — the same little sprite for a sofa, a globe, or a fridge. This mod replaces that leaf with the furniture's actual 3D model, so a dropped sofa looks like a sofa sitting in your yard. Later Animal Crossing games let you decorate outdoors; this is a small step in that direction for the GameCube one.

The leaf was never a hard limit — two systems were just never wired together. The dropped-item code knew how to draw leaves and presents; the furniture code knew how to draw the real models, but only ever inside houses. The catch was finding the right seam. A dropped item is only a falling "animation" for about one frame; the instant it lands it becomes a static ground item drawn by a completely different, batched renderer. So the leaf you actually see resting on the grass comes from that grid renderer, not the drop — and that is where the model has to be drawn instead. We spent a good while fixing the drop animation before realizing it wasn't what was on screen.

Then the models rendered as nothing. Two reasons. First, they were being drawn at height zero — underground — so a small piece was fully buried and a big one poked just its roof through the lawn; placing each model on the actual terrain height fixed it. Second, the indoor draw routine quietly relies on render state the outdoor scene never sets, so the furniture was technically drawing but clipped to invisible until we matched the catalog's exact setup. The proof it finally worked was a globe rendering as an unmistakable orange-and-blue sphere on a stand, sitting in the grass.

From there it grew to cover almost everything: plain furniture, multi-tile pieces like beds and sofas centered on their footprint, appliances, the skeletal pieces (clocks with moving hands, decorated trees), and the "unique" items that draw themselves — TVs, gold tools, balloons, museum fossils, the shop and building models. Furniture that doesn't fit gets the game's own "you can't put it here" message, now aware that a two-tile piece needs two free tiles. Only a handful of custom-design items (mannequins, your own umbrella) still fall back to the leaf.

Before merging, a fleet of review agents read the whole change and caught two things our playtests had walked right past: a way the new placement check could quietly delete a piece of furniture from your save, and a specific alarm clock that crashed the game when dropped. Both are fixed, and the save was checksum-verified to round-trip cleanly — dropping furniture never corrupts your town.

May 27 – June 9

Multiplayer visits are live

As of June 9 you can visit a friend's town on the live site (both players signed in). The mechanism is the one the GameCube shipped in 2002: travel by memory card. When a host accepts your request, a copy of their save downloads to your browser, installs as the second memory card, and your game reboots into the real travel flow — you ride the train in and arrive as a visitor because, as far as the engine knows, you are one. Nothing injects a second player into a running game; the game's own systems do the work.

On top of that sits the live layer. Both browsers join a room relayed through our server over a WebSocket — no peer-to-peer, so it works the same behind any home router. Position and animation state stream twenty times a second; everything else relays as discrete events: tree shakes, dug and filled holes, pulled weeds, caught fish and bugs, board posts, shop stock, weather, emotes. The other player is drawn by a new engine actor that consumes that stream, and every value off the wire is bounds-checked before it indexes a table — the far end of a network connection is untrusted even when it's your friend.

The fussiest rendering problem was a player holding a tool while running. No single animation does that — the real player code blends two tracks, the tool pose on the arms and the locomotion on the legs, with a per-joint table deciding which track owns which bone. The remote avatar now does the same blend, which is why a visitor's rod casts and net swings read correctly instead of freezing mid-arm. Effects fire at the real skeleton joints on the real animation frames — the dig spark lands on frame 22 of the shovel swing, not on the button press.

We almost built it the other way. For two days we chased lockstep: pin the random seed and the clock, feed both games identical inputs, and the two towns stay byte-for-byte identical — we proved it works, villager AI included. But a leftover debug switch silently armed the seed-pinning in a real player's session and broke their visit, stranding them at the train station, so we deleted the whole path and shipped the relay instead. The host's town is the source of truth: what you change there persists there, the copy on your machine is discarded when you leave, and your own town is untouched.

The same release rebuilt save management — one Save panel, a local backup taken automatically before anything destructive, and cloud conflicts that ask instead of guessing, with the side you don't pick kept as a backup — and fixed the train station sometimes failing to spawn, which is what froze Porter mid-dialog. Launch day shipped one bug worth owning: for about ten minutes the site served the new page with the previous game binary, because a fast frontend-only deploy raced a slow fifteen-minute game build and grabbed the stale artifact. The pipeline now stamps the game binary with the exact source tree it was built from and refuses to deploy a mismatched pair.

June 4

Sitting on rocks and stumps

A small comfort feature: walk into a town rock or a cut-tree stump and your character climbs up and takes a seat, facing back the way you came. Push the stick to stand and you hop down. The original game already has a full sit animation — it is used for chairs indoors — so most of the work was pointing that existing behavior at a rock instead of furniture.

The tempting way to add a new pose is to add a new player state. That turned out to be a trap. The player's state is an index into a row of lookup tables — animations, collision behavior, what each frame is allowed to do — and the bounds check just compares the index against a state-count constant. Bumping that constant for the new state made the new index "valid" against tables that still only had rows for the old states, so the game would read whatever sat past the end — garbage animation priorities, garbage sound IDs. We caught it in review and backed the approach out. The fix was to stop inventing a state and instead reuse the real sit state with a single "this time it's a rock" flag, which also meant nothing downstream — including the code that shows other players to each other online — needed to learn a new pose. A visitor sees you up on the rock nearly for free; the one follow-up taught the remote view to snap the sit's instant about-face instead of visibly spinning into the seat.

Two bugs were pure geometry. The sit animation assumes a chair at floor height, so on a rock the character sat at ground level, half-buried; we now hold the seat at the rock's height for every frame you stay put, ease you back down to the walkable tile when you stand, and if anything else yanks you out of the seat — a save, a cutscene — snap you straight there so nothing ever catches you floating. And getting off did not work at first: the same "walk into it" trigger that sat you down fired again the moment you pushed away, re-seating you forever. Standing up now disarms the trigger until you let go of the stick, so the push that stands you up cannot also sit you back down.

May 19 – 20

HD textures and the mobile GPU wall

Desktop gets every dial turned up: a large backing buffer, anti-aliasing, and optional HD texture packs. On phones those same defaults reloaded the tab over and over with nothing in the logs — because running out of GPU memory kills the tab before any crash handler can write anything down. The browser drops the graphics context, the shell reloads to recover, the fresh boot runs out of memory again, and around it goes.

We confirmed the base game itself is stable on mobile by running it for several minutes under an emulated phone with a throttled CPU: memory stayed flat and the graphics context never dropped. The growth was the HD textures, which live in GPU memory no JavaScript API can even measure. So mobile now clamps the resolution, turns off anti-aliasing, and disables the HD pack; desktop keeps all of it.

The HD packs had a twist of their own. They ship as compressed textures in the format the Dolphin emulator uses, and the browser starts with compressed-texture support switched off. Desktop OpenGL hands it out automatically, so the desktop build worked — but on the web all 17,427 textures in a pack failed to load until we turned the right WebGL2 extensions on explicitly.

May 5 – 19

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 back to suspended, so the game re-wakes it on every tap and keypress, with a once-a-second keepalive covering the input-free boot. Together those fixed an early complaint where sound only kicked in 30 to 60 seconds after loading.

Then the timing. An early version generated audio on only one frame in eight, producing an eighth of the samples the output needed, which came out pitched and garbled. Removing that throttle fixed the pitch but exposed the opposite problem: on iPhone, sounds arrived almost a second late. iOS adds a few hundred milliseconds of its own, and our buffer was targeting another half second on top. We kept the large buffer for boot, where it rides out the stutter while the engine warms up, 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.

May 13 – 16

Cloud saves, kept honest

Your disc image never leaves your browser, but saves go online so a town can follow you between devices. They live in the game's in-memory filesystem, which we were already persisting to the browser's IndexedDB and pulling back on the next visit, holding the boot until the old save finishes loading in — cloud sync ships that same bundle to a server.

Then two bugs that looked impossible. First, the cloud kept insisting your save and the cloud copy had diverged and showing a conflict prompt when nothing had changed: same files, same total size, different fingerprint. We hash the bundle of save files to compare them, but the filesystem returned the files in whatever order it rebuilt them from IndexedDB, and that order is not stable. Two exports of the identical save in different orders produced different bytes and a false conflict. Sorting the files before hashing fixed it.

The second came from the iframe. The game runs inside an iframe, with its own JavaScript world separate from the page around it. When your cloud save downloads, the bytes are built by the outer page and handed to the game inside — and both sides have a Uint8Array type, but they are not the same type. The game's own "is this really a byte array" check returned false for an obvious byte array built by the neighbor, so every cloud-save restore died with a generic "Sync error" and a clean console — no clue why — even though the download returned the right bytes. The same mismatch hung the very first drag-drop of a ROM. The fix is to copy the incoming bytes into a fresh array that belongs to the iframe — one memcpy of a 27 MB file, which is fine.

May 9 – 14

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. Early on the project had a pile of these skip flags, 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 paid off immediately, because some of the stalls were genuine bugs WebAssembly refuses to hide. When a function contains undefined behavior, the optimizer is allowed to assume that code can never run and replace the entire function with a single trap. On the GameCube the same code just worked. Three real ones:

  • The intro cutscene never advanced because a setup routine was missing its return statement and handed back garbage. The game read that as "this NPC was not born yet" and re-spawned every NPC every single frame, resetting their animations before they could play. A timeout would have hidden this forever; restoring the return let the train arrival and Rover's dialog play through for real, and the rest of the first-day intro — Tom Nook walking you to your house — was fixed on top of it over the days after.
  • Talking to certain villagers hard-crashed the game. The cause was an angle macro computing (s16)(180 * (65536.0 / 360)) — exactly 32768, one past the top of a signed 16-bit integer, so the float-to-int conversion is undefined, and the optimizer deleted the entire thought-bubble effect down to a trap. On hardware it silently wrapped to the right number.
  • A sound opcode could trap because its handler fell off the end of a function that was supposed to return a value. On PowerPC the right value happened to be left in a register, so it "worked"; in wasm that is undefined, so any sequence using that opcode killed the audio engine. The fix is a one-line return.

All three came straight from the faithful decompilation — they were never bugs on real hardware, and two of the three are still in the upstream source today (upstream has since fixed the missing return itself). Sweeping the compiled output for functions the optimizer had quietly turned into traps turned out to be a good way to find them.

May 5 – 8

First pixels on the canvas

Getting the first frame on screen meant getting past WebAssembly's strictness. The engine is full of callback tables where a slot expects one function signature and gets another. PowerPC does not check; WebAssembly checks every indirect call and traps on a mismatch. One froze the game on the first transition into the town — a wipe-effect color function declared as taking no arguments sat in a slot that called it with two, so the fade between the train cutscene and the town trapped mid-wipe and hung the loop. We built a generator that emits typed wrapper functions for these slots (185 wrappers, their call sites rewritten across some 210 files) plus hand-fixes for the stragglers, and the town finally rendered.

The other early fight was speed: for a while busy scenes crawled at well under one frame per second. The biggest culprit was not the game — 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. We moved per-frame chatter behind an off-by-default flag and wrote the log to an in-memory buffer instead of the page. That, on top of an audio-pipeline early-out from the day before, took a heavy scene from roughly 0.4 frames per second to 50 to 100 of raw throughput — past the 60 Hz the game's frame pacer presents, so it simply runs at full speed. Most of the performance work on this project was finding accidental per-frame costs like that one, not optimizing the game.

The starting point

A port, not an 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. A community decompilation recovered the game's source, a separate project ported that source to run as a normal PC program, and we compile that PC port to WebAssembly with Emscripten — swapping the desktop pieces (window, input, audio, file access) for browser ones. There is no emulated console in the middle: the actual game logic runs as wasm, which is lighter and faster than simulating the hardware. The cost is that you inherit every quirk of twenty-year-old console code, in a far stricter place to run it.

One thing lined up for free. The original engine stores pointers in 32-bit fields, so the whole project builds as a 32-bit program — and wasm32 is also 32-bit, so that assumption held. Almost nothing else was that easy. The decompilation and the PC port carry months of upstream work this project stands on; getting that code to compile cleanly under Emscripten at all is where the browser port began, in early May.

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.