Tag: bevy

  • How to Build an Idle Game in Rust with Bevy

    Bevy’s ECS architecture is a natural fit for idle games. Resources hold game state; Systems run on a schedule; Events trigger state transitions. The core idle loop — tick, accumulate, spend — maps cleanly to Bevy’s data model without needing a game engine that hides the loop from you. Here’s how to structure it and where the real problems are.

    ## Why Bevy Works for Idle Games

    An idle game is fundamentally a state machine with timers. Resources accumulate at rates determined by upgrades. Upgrades cost resources. The player’s decisions change rates, not the loop itself.

    Bevy’s ECS handles this pattern better than most frameworks because the data model matches the problem model directly. A `Resource` in Bevy is global mutable state — exactly what an idle game’s currency pool is. A `System` running on a timer is exactly what a production tick is. An `Event` is exactly what a player action produces.

    You don’t need to fight the architecture. You’re using it as intended.

    ## The Core Loop in ECS Terms

    **Resources for game state:**

    “`rust
    #[derive(Resource)]
    struct EnergyPool {
    current: f64,
    per_second: f64,
    }

    #[derive(Resource)]
    struct ProductionState {
    active_producers: Vec,
    }
    “`

    **A System for the production tick:**

    “`rust
    fn production_tick(
    time: Res

    **Events for player actions:**

    “`rust
    #[derive(Event)]
    struct UpgradePurchased {
    upgrade_id: String,
    }

    fn handle_upgrade(
    mut events: EventReader,
    mut production: ResMut,
    mut energy: ResMut,
    ) {
    for event in events.read() {
    // deduct cost, add producer, update rate
    }
    }
    “`

    This is the complete structure of an idle game in Bevy. Everything else is content, configuration, and UI.

    ## Configuration Over Constants

    Idle game balance is tuned by feel, not derived from first principles. The cost of the third producer, the rate of the second tier, the unlock threshold for the late-game mechanic — all of these change many times during development.

    Hard-coding these as Rust constants means recompiling every time a number changes. The correct approach is external config files loaded at startup:

    “`toml
    # balance.toml
    [producers]
    base_rate = 1.0
    cost_curve_exponent = 1.5
    tier_unlock_threshold = 100.0
    “`

    Serde + a custom `Asset` implementation lets Bevy load TOML at startup. During development, hot-reloading the config without restarting the game is achievable via Bevy’s asset server watch mode. The game ships with the final config baked in; during development, you tune numbers in TOML and see changes in seconds.

    This pattern applies to anything tunable: producer rates, UI timing, narrative trigger thresholds, visual parameters.

    ## UI With bevy_egui

    bevy_egui is the standard choice for immediate-mode UI in Bevy. It integrates the egui library, which handles layout, input, and rendering in a single pass per frame.

    The basic pattern:

    “`rust
    fn ui_system(
    mut contexts: EguiContexts,
    energy: Res,
    mut upgrade_events: EventWriter,
    ) {
    egui::Window::new(“Production”).show(contexts.ctx_mut(), |ui| {
    ui.label(format!(“Energy: {:.1}”, energy.current));
    if ui.button(“Buy Producer”).clicked() {
    upgrade_events.send(UpgradePurchased {
    upgrade_id: “producer_1”.to_string(),
    });
    }
    });
    }
    “`

    ## The Complication: bevy_egui Click Handling in 0.33

    `egui::Window` button clicks are unreliable when driven from Bevy’s `Update` schedule in bevy_egui 0.33. Buttons render correctly and respond visually to hover, but `.clicked()` doesn’t register consistently. This isn’t a code error — it’s a scheduling interaction specific to this version.

    **The locked workaround:** use `painter + ui.interact()` instead of relying on `egui::Window` widget click handling for any interaction you care about:

    “`rust
    fn ui_system(mut contexts: EguiContexts) {
    let ctx = contexts.ctx_mut();
    let painter = ctx.layer_painter(egui::LayerId::new(
    egui::Order::Foreground,
    egui::Id::new(“game_ui”),
    ));

    let button_rect = egui::Rect::from_min_size(
    egui::pos2(10.0, 50.0),
    egui::vec2(120.0, 30.0),
    );

    let response = ctx.interact(
    button_rect,
    egui::Id::new(“buy_button”),
    egui::Sense::click(),
    );

    if response.clicked() {
    // fire your event here
    }

    painter.rect_filled(button_rect, 4.0, egui::Color32::from_rgb(60, 120, 200));
    painter.text(
    button_rect.center(),
    egui::Align2::CENTER_CENTER,
    “Buy Producer”,
    egui::FontId::proportional(14.0),
    egui::Color32::WHITE,
    );
    }
    “`

    This pattern is more verbose but reliable. Any new UI interaction needs to use this approach rather than standard widget click handling.

    ## WASM Build and itch.io Deployment

    Bevy compiles to WASM via the `wasm32-unknown-unknown` target with Trunk as the build tool. The WASM build has specific requirements that don’t apply to desktop:

    **Canvas ID.** Trunk expects a specific canvas ID in `index.html`. The Bevy WASM runner looks for this same ID. A mismatch produces a blank canvas with no error — the game initializes but renders nowhere. Set `canvas = “#bevy”` in your Bevy app and ensure `` is in `index.html`.

    **Public URL.** The `trunk.toml` `public_url` must be `”/”` for local development and `”./”` for itch.io deployment. itch.io serves files from a subdirectory CDN path; an absolute public URL (`”/”`) produces 404s for all assets when hosted on itch.io.

    **Local storage.** `localStorage` is not available without a `cfg(target_arch = “wasm32”)` guard. Any save/persistence code that calls web APIs must be conditionally compiled:

    “`rust
    #[cfg(target_arch = “wasm32”)]
    fn save_to_local_storage(data: &str) {
    // web_sys::window()…
    }

    #[cfg(not(target_arch = “wasm32”))]
    fn save_to_local_storage(data: &str) {
    // write to file or skip
    }
    “`

    Desktop save logic and WASM save logic are separate implementations behind the same function signature.

    **Deployment.** Butler (itch.io’s CLI tool) handles upload: `butler push dist rdug627/my-game:html5`. The `dist` directory is what Trunk produces. A publish script that runs `trunk build –release` followed by `butler push` makes deployment a single command.

    ## FSM for Game State

    Idle games have distinct phases: tutorial, early game, mid game, unlocks, end state. Bevy’s `States` system is the right tool for managing phase transitions.

    The pattern that avoids a common failure class: define a `GameState` enum and use `OnEnter` and `OnExit` systems to manage phase-specific setup and teardown. Don’t check game state in every system — gate the system’s schedule registration to the states where it should run.

    The failure class this avoids: production systems running during the tutorial before the player has producers, or tutorial UI rendering during late-game when it’s been dismissed. State-gated systems only run in their state.

    ## Frequently Asked Questions

    **Does Bevy 0.15 have good WASM performance?**
    For idle games — yes. Idle games are not GPU-intensive; the WASM performance budget is rarely a constraint. Complex particle systems or large sprite counts will hit limits, but a resource-accumulation idle game runs smoothly in browser at any reasonable feature set.

    **Should I use Bevy or a simpler framework for a first idle game?**
    Bevy’s learning curve is real — ECS is different from object-oriented game architecture, and the compiler errors are verbose. For a first idle game, the ECS model is actually a good fit because idle games are naturally data-driven. The framework is worth learning once; after that, the architecture scales cleanly.

    **How do I handle save/load for WASM?**
    Local storage is the right target for WASM saves. A JSON-serialized snapshot of your Resource state, written to local storage on meaningful events (purchase, significant accumulation), loads on startup if present. The conditional compilation pattern above keeps the desktop and WASM paths separate.

    **What about mobile (Android)?**
    Bevy compiles to Android via the `cdylib` crate target and Android NDK. The UI constraints are different — mobile safe zones, touch hit targets, no hover states. The egui painter pattern is particularly important on mobile since touch events on small button targets need explicit hit area control.

    ## Two Games Built on This Stack

    **VoidDrift** (rdug627.itch.io/voidrift) — an idle space mining game built with this exact architecture. WASM and Android, live on itch.io, pay what you want.

    **OperatorGame** (itch.io) — a slime agent deployment game using the same Bevy chassis. Demonstrates the architecture at a different scale and mechanic set.

    Both repos follow the pattern described here: TOML config, ECS resource loops, painter-based UI interaction, Trunk WASM build, Butler deployment.

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

    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.

  • From Pong AI to Play Store: How a Childhood Hobby Became a Rust Game Engine

    The first game I wrote with any real ambition wasn’t a game. It was a NEAT implementation that learned to play Pong. I wasn’t trying to ship anything — I was trying to understand how a system could learn to do something I taught it.

    That question has been running in the background of everything I’ve built since.


    TurboShells came next. I took the NEAT studies from PyPong and asked: what if instead of teaching an AI to hit a ball, I bred turtles? Each turtle’s body was drawn entirely from its genes — no sprites, pure math. Shell radius, leg length, color — all expressed from a genetic sequence at render time. They raced. The faster ones bred. The slower ones didn’t.

    Nobody played TurboShells. But I learned something: the genetics loop — dispatch a breeding pair, wait for the outcome, observe the consequence — was more interesting to me than any game mechanic I’d seen. I wasn’t building a racing sim. I was building a system that made things happen without me.


    ChimeraLab was the first time I tried to give the genetics a body.

    Custom 3D creature rendering. SpineComponents — oblongs stacked together, body parts articulated from code. I got a humanoid assembled. I gave it a skeleton. I ran the simulation.

    It fell to the floor under its own weight.

    I never did fix the bipedal problem. But I got it to transition from two legs to four using a slider, and watching that happen — a creature renegotiating its relationship with gravity in real time — taught me more about 3D rendering than any tutorial I’d read. Sometimes the failure is the lesson.


    rpgCore was the foundation I should have built first. A thousand tests. Real ECS architecture. Genetics, lifecycle, dispatch — everything composable, everything verified. SlimeGarden put it to work: an astronaut crash-lands on an unusual planet and finds slimes. Breed them. Dispatch them. See what comes back.

    It sounds simple. It wasn’t. And it pointed somewhere.


    Seven projects in, I was staring at the Google Play Store submission checklist.

    OperatorGame ran on Android. Real Rust, real Bevy, real APK on a real phone. The combat worked. The UI was clean. I’d solved the hard problems.

    The submission required a 512×512 app icon, a 1024×500 feature graphic, and two screenshots.

    I didn’t have any of them.

    I could have made them. It would have taken an afternoon. But sitting there looking at that checklist, I realized the assets weren’t the problem.

    The problem was I had no audience. I was about to pay the Play Store’s attention tax — discoverability weighted toward downloads, downloads toward reviews, reviews toward players who found you somehow — with zero players behind me. I wasn’t Android Store money-ready. I was Itch.io audience-ready.

    Those are different things. Confusing them is expensive.


    Here’s what the journey looked like from the inside:

    PyPong AI taught me how systems learn. TurboShells taught me that genetics loops are more interesting than game mechanics. ChimeraLab taught me that creatures fall down and that’s instructive. rpgCore gave me the foundation. SlimeGarden gave the foundation a story. OperatorGame proved the Android pipeline. VoidDrift took the dispatch loop — Scout mines ore, returns, consequence — and dressed it in something people want to watch.

    Every project is the same loop. Something dispatches. It does its work. It returns with a result. Something changes.

    I’ve been building that loop for years. I just didn’t see it until I looked at the list.


    The lesson isn’t “don’t aim for the Play Store.”

    It’s: know what you’re ready for. The Play Store is a distribution problem you solve after you have players, not before. Itch.io is where you find out if anyone cares. If they do, the Play Store is a next step. If they don’t, you learned that cheaply instead of expensively.

    VoidDrift is on Itch right now. A small audience that keeps coming back. That’s the signal I was missing with OperatorGame.

    When the audience is real, the Play Store assets take an afternoon.


    There’s a story that keeps circling my mind. Someone in a ship, traveling through a black hole, becoming something else. They find a station. What follows is a macabre exploration of self — what survived the transit, what didn’t, what the new thing is capable of.

    VoidDrift is the ship and the void. SlimeGarden is the crash-landing after.

    The loop doesn’t end at the Play Store. It ends when the story does.

    The Scout dispatch loop in VoidDrift is the same loop TurboShells was running — breed, wait, observe — dressed in space mining clothes, nine projects later. Phase 5 is live. The Play Store listing is three PNGs away.

    The story is still circling. I’m still building toward it.

  • Building a Mobile Idle Game in Rust/Bevy Without a Game Engine Background

    Building a Mobile Idle Game in Rust/Bevy Without a Game Engine Background

    The line appeared one night and wouldn’t leave:

    The station has been here longer than you. It should have been consumed. It has not been. You don’t know why.

    I didn’t know what kind of game it belonged to. I spent months carrying it before I found out.


    VoidDrift is a mobile idle game where you mine asteroid debris at the edge of a black hole. You build a drone fleet. Factions you don’t understand start sending messages. There’s no win condition. The horizon is a one-way membrane.

    I had no game engine background when I started. I’d built systems — ECS architectures in Python, simulation loops, procedural generators — but I’d never shipped a game. I picked Rust and Bevy because the pipeline I needed (Android and WASM from a single codebase) pointed there. The ECS paradigm took time to click. My first systems were monolithic messes that tried to own too much.


    The breakthrough wasn’t a technical insight. It was administrative discipline.

    I forced myself to write an ADR — an Architecture Decision Record — before touching any major decision. ADR-003: bevy_egui for all HUD because Mali GPU stabilization on Android required it. ADR-007: system partitioning because Bevy’s scheduler hits a 20-tuple limit faster than you expect. Every constraint that would have sent me into a three-day refactor got documented before it became one.

    Bevy 0.15 with bevy_egui 0.33 has a specific problem nobody warns you about: egui::Window click events are broken in the Update schedule. The fix is painter + ui.interact(). I know that because I hit the wall, diagnosed it, and wrote it down. The ADR system means I don’t rediscover the same walls twice.


    What didn’t work: scope. I wanted branching faction storylines, a full three-ring resource economy, Mk II drone tiers, a complete Human-versus-Signal narrative arc. None of that is in the current version. What shipped is a focused mining loop, a production tree, and enough faction voice to suggest something larger without explaining it.

    The unexplained parts are intentional now. They weren’t when I started.


    VoidDrift is live on itch.io — Android and WASM from the same codebase. 505 views, 10.4K impressions. A small audience that keeps coming back.

    The Play Store is three assets away. An app icon, a feature graphic, two screenshots. Not a code problem. An afternoon problem I haven’t made time for yet.


    The station is still there. The factions are still watching. The black hole is still waiting.

    Some games tell you everything. VoidDrift tells you enough to make you wonder about the rest. That constraint — deliberate incompleteness — turned out to be the best decision I didn’t plan to make.