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 `
**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.
Leave a Reply