I Shipped a Game to Android and the Web From the Same Codebase

Written by

in

The game is called VoidDrift. You mine ore in orbit around a dying star, build a production chain, and feed a black hole that is slowly eating everything. It runs in a browser. It runs on Android. The same code does both.

I didn’t plan for that to be interesting. It turned out to be the hardest part of the whole project.

The Decision

When I started VoidDrift I was building in Rust with Bevy, a game engine that’s relatively young and takes strong opinions about how game logic should be structured. The choice to target both WASM — which runs in a browser — and Android wasn’t a roadmap item. It started as a question: if the game runs on my machine, how much work is it to make it run everywhere?

The answer turned out to be: more than you’d expect, and less than you’d fear. But the path between those two things involves a specific class of problem that nobody warns you about.

The browser and Android are not the same target. They have different input models, different screen assumptions, different rendering constraints, different deployment pipelines. Building for one teaches you nothing about building for the other. Building for both at the same time forces you to find the seam where your game logic has made assumptions it shouldn’t have.

That seam is where VoidDrift got redesigned.

What Breaks First

The first thing that breaks is your assumption about screen size.

I tested on my development machine. Everything looked correct. I deployed to my Android device — a Moto G 2025 — and watched half my UI disappear behind the operating system’s navigation bar. The bar that lives at the bottom of the screen, with the back button and the home button, was sitting on top of my game without telling me.

The device reports its screen resolution one way. The usable area after the OS takes its share is something different. I had built the entire UI assuming the numbers the device reported were the numbers I’d actually get. They weren’t.

This sounds like a small problem. It took a full session to diagnose and fix because the failure mode was invisible on every platform except the physical device. The emulator didn’t reproduce it. The browser didn’t have it. Only the real hardware showed the real problem.

That’s the tax on multi-platform development: the bugs that only exist on one target, found only by running on that target. You can’t test your way around physical hardware.

What the Browser Does Differently

The browser version has a different class of problem. WASM — WebAssembly, the format that lets Rust code run in a browser — imposes constraints on how your game loop can work. Things that are straightforward on a native target become negotiable in the browser.

The biggest one for VoidDrift was the tutorial. The tutorial walks new players through the core loop — mining, forging, building. It works correctly in the native build. In the browser it was broken in a way that was difficult to pin down: state that should have persisted wasn’t, transitions that should have triggered weren’t.

The fix required understanding how WASM handles the execution context differently from native, and adjusting the tutorial’s state machine to match. The game logic didn’t change. The assumptions the game logic was making about its environment had to.

This is the pattern with multi-platform work: you don’t change what the game does, you change what the game assumes about where it’s running.

The Architecture That Made It Possible

VoidDrift survived the multi-platform problem because of a decision made early: the game logic, the economy logic, and the world logic live in separate modules. They don’t know about each other directly. They communicate through a shared interface.

When the Android problem surfaced, I fixed it in one place. When the WASM problem surfaced, I fixed it in one place. Neither fix touched the game logic that was working correctly on both targets.

This sounds like standard software engineering advice, and it is. It’s also advice that’s easy to ignore when you’re building a game because games have a tendency to grow organically — you add the feature where it’s convenient, not where it belongs. The convenience debt compounds until you’re in a situation where fixing a UI bug requires touching three files that have nothing to do with UI.

The architecture decision wasn’t made because I foresaw the multi-platform problems. It was made because the codebase was getting hard to reason about and I needed it to be legible again. The multi-platform resilience was a side effect of the legibility project.

That’s usually how it works.

What Shipping Actually Meant

VoidDrift is live on itch.io. Browser version loads directly in the page. Android version downloads and installs. Both built from the same repository, deployed with a single script.

505 views. 239 plays. 10,400 impressions.

Those aren’t large numbers. They’re real numbers, which is different from the numbers a game has before it ships. Before you ship, the number is zero and you’re making decisions based on what you think will happen. After you ship, the number is whatever it is and you’re making decisions based on what actually happened.

The browser version gets played more than the Android version. That was surprising. The assumption going in was that mobile would dominate — people play games on their phones. The reality is that someone encountering an idle game in a browser is more likely to click play than someone who has to download and install an APK first. Friction matters. The format that removes friction wins.

That’s the kind of thing you only learn by shipping to both and watching what happens.

What I’d Tell Someone Starting This

Target one platform first. Get it working. Get it shipped. Then add the second target with eyes open to the assumption problem.

The assumption problem is this: every line of code you write makes an assumption about the environment it will run in. Most of those assumptions are invisible until the environment changes. Changing to a new platform surfaces all of them simultaneously, which is overwhelming. Changing platforms after you already have a working, shipped product means you have a stable baseline to compare against when something breaks.

VoidDrift on Android broke in ways VoidDrift on WASM didn’t. VoidDrift on WASM broke in ways VoidDrift on Android didn’t. Neither set of breaks was predictable in advance. Both were fixable because the codebase was organized well enough to isolate them.

Ship the first version. Let the second platform teach you what the first version assumed.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *