Bevy Book
A hands-on reference for building a 2D game with Bevy 0.18 - from first window to procedural worlds, data-driven characters, collision systems, inventory, and weather effects.
Special shoutout to Febin John James — thank you for your support and contributions!
Table of Contents
Chapter 1: Getting Started with Bevy
- 1. Project Setup
- 2. Core Architecture - Thinking in Systems
- 3. Step-by-Step Implementation
- 4. Adding Sprite Graphics
- 5. Rust Concepts Quick Reference
- 6. Bevy Memory & Performance Notes
- 7. Recommended Crates for Expanding This Project
- 8. Tips for Further Improvement
- 9. Complete File Structure
- 10. Full Schedule Reference
Chapter 2: Procedural Tilemap Generation with WFC
- 1. Chapter Overview
- 2. Wave Function Collapse (WFC) - How It Works
- 3. Project Setup
- 4. Architecture Overview
- 5. Step-by-Step Implementation
- 6. Rust Concepts Introduced in This Chapter
- 7. Quick Weight Tuning Guide
- 8. Known Issues & Workarounds
- 9. Recommended Crates for Expanding Procedural Worlds
- 10. Tips for Further Improvement
- 11. Complete Layer Stack Visualization
Chapter 3: Data-Driven Characters
- 1. Chapter Overview
- 2. Project Setup
- 3. The RON Configuration File
- 4. Architecture - System Flow
- 5. Data Types
- 6. Animation Engine
- 7. Movement System
- 8. Spawn & Character Switching
- 9. Plugin Registration
- 10. Rust Concepts Introduced
- 11. Tips for Further Improvement
- 12. Recommended Crates
- 13. Spritesheet Layout Reference
- 14. Complete Component Hierarchy
Chapter 4: State Machines & Collision
- 1. Chapter Overview
- 2. Project Structure After This Chapter
- 3. Game States
- 4. Character State Machine
- 5. Architecture Refactor
- 6. Collision System
- 7. Player Collider
- 8. Debug Visualization
- 9. Y-Based Depth Sorting
- 10. Centralized Configuration
- 11. Complete Player Entity Components
- 12. Rust Concepts Introduced
- 13. Controls Summary
- 14. Known Issues & Tips
- 15. Recommended Crates
Chapter 5: Inventory & Camera
- 1. Chapter Overview
- 2. Project Structure After This Chapter
- 3. Inventory System
- 4. Camera System
- 5. World Scale Changes
- 6. Updated main.rs
- 7. Centralized Config - Full State
- 8. Rust Concepts Introduced
- 9. Controls Summary (Cumulative)
- 10. Tips for Further Improvement
- 11. Recommended Crates
Appendix A: Building a Rain System
- Step 1: Set Up the Weather Module
- Step 2: Create the Weather State
- Step 3: Make It Rain - Spawning Raindrops
- Step 4: Move and Clean Up Raindrops
- Step 5: Darken the Screen
- Step 6: Slow the Player
- Step 7: Spawn Puddles
- Reference: Z-Ordering Cheat Sheet
- Challenges: Take It Further
Bevy & Rust Game Development - Chapter 1 Reference
1. Project Setup
1.1 Create the Project
cargo new bevy_game
cd bevy_game
1.2 Add Bevy Dependency
In Cargo.toml:
[dependencies]
bevy = "0.18"
1.3 Speed Up Compile Times (Important)
Bevy is a large crate. Add these to your project for dramatically faster iteration:
.cargo/config.toml (create this file):
These flags switch the linker to lld, which links Bevy’s large number of crates significantly faster than the default linker. On macOS, the equivalent uses LLVM’s ld64.lld.
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]
# macOS equivalent
[target.aarch64-apple-darwin]
rustflags = ["-C", "link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld"]
Cargo.toml profile tweaks:
# Enable optimizations for dependencies in dev builds (huge perf boost)
[profile.dev.package."*"]
opt-level = 3
# Enable dynamic linking for Bevy (faster recompiles)
[features]
default = ["bevy/dynamic_linking"]
Tip: Install
lldormoldas your linker. On Linux:sudo apt install lld. This alone can cut link times from 10s+ to under 1s.
2. Core Architecture - Thinking in Systems
Bevy is built on an Entity Component System (ECS) with two fundamental patterns:
| Pattern | Schedule | Runs | Use Case |
|---|---|---|---|
| Setup | Startup | Once at launch | Spawn cameras, players, load assets |
| Update | Update | Every frame | Input handling, movement, animation, AI |
Key Concepts
- Entity: A unique ID (like
#42) - holds no data itself. - Component: Data attached to an entity (position, health, sprite, tags).
- System: A function registered with Bevy that runs at a scheduled time.
- Resource (
Res<T>): Game-wide singleton data not tied to any entity (time, input state, asset server). - Bundle: A group of components spawned together for convenience.
- Plugin: A struct implementing
Pluginthat registers systems, resources, and events - keeps code modular.
3. Step-by-Step Implementation
Step 1 - Minimal Window with Camera
// src/main.rs
use bevy::prelude::*;
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.run();
}
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
}
What happens: App::new() creates the application → DefaultPlugins loads rendering, input, audio, and windowing → setup runs once at startup → run() enters Bevy’s main loop (poll input → run systems → render frame → repeat).
Tip:
DefaultPluginsis configurable. You can swap out the window plugin to set title, resolution, vsync, etc:#![allow(unused)] fn main() { DefaultPlugins.set(WindowPlugin { primary_window: Some(Window { title: "My Game".into(), resolution: (1280., 720.).into(), ..default() }), ..default() }) }
Step 2 - Player Component (Tag Marker)
#![allow(unused)]
fn main() {
#[derive(Component)]
struct Player;
}
struct Player;is a unit struct - zero-size, used purely as a tag.#[derive(Component)]is a derive macro that generates the boilerplate Bevy needs to store/query this type.- Tag components let systems query for specific entities: “give me the entity with the
Playertag.”
Tip: You’ll use this pattern constantly. Common tags:
Player,Enemy,Bullet,Wall,NPC.
Step 3 - Spawn the Player (Text Placeholder)
#![allow(unused)]
fn main() {
fn setup(mut commands: Commands) {
commands.spawn(Camera2d);
commands.spawn((
Text2d::new("@"),
TextFont {
font_size: 12.0,
font: default(),
..default()
},
TextColor(Color::WHITE),
Transform::from_translation(Vec3::ZERO),
Player,
));
}
}
What’s spawned - conceptual entity table:
| Entity | Components |
|---|---|
#0 | Camera2d |
#1 | Text2d("@"), TextFont, TextColor, Transform, Player |
commands.spawn(( ... ))takes a tuple of components treated as a bundle.Transform::from_translation(Vec3::ZERO)places the entity at world origin(0, 0, 0)...default()fills remaining struct fields with defaults - a Rust pattern called struct update syntax.
Step 4 - Player Movement System
#![allow(unused)]
fn main() {
fn move_player(
input: Res<ButtonInput<KeyCode>>, // Keyboard state
time: Res<Time>, // Frame delta
mut player_transform: Single<&mut Transform, With<Player>>, // The player's position
) {
let mut direction = Vec2::ZERO;
if input.pressed(KeyCode::ArrowLeft) { direction.x -= 1.0; }
if input.pressed(KeyCode::ArrowRight) { direction.x += 1.0; }
if input.pressed(KeyCode::ArrowUp) { direction.y += 1.0; }
if input.pressed(KeyCode::ArrowDown) { direction.y -= 1.0; }
if direction != Vec2::ZERO {
let speed = 300.0;
let delta = direction.normalize() * speed * time.delta_secs();
player_transform.translation.x += delta.x;
player_transform.translation.y += delta.y;
}
}
}
Important details:
Single<&mut Transform, With<Player>>- queries for exactly one entity matching the filter. Panics if zero or multiple match. Perfect for a single-hero game.direction.normalize()- converts the vector to length 1 so diagonal movement isn’t ~41% faster than cardinal movement.time.delta_secs()- frame-time-independent movement. The player moves at the same speed regardless of FPS.Res<T>is an immutable resource borrow;ResMut<T>is mutable.
Tip: For multiple players or more flexibility, use
Query<&mut Transform, With<Player>>instead ofSingle. Iterate withfor mut transform in &mut query { ... }.
Step 5 - Register the Movement System
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, setup)
.add_systems(Update, move_player) // Runs every frame
.run();
}
4. Adding Sprite Graphics
4.1 Spritesheet Source
Use the Universal LPC Spritesheet Generator to create character spritesheets. Export as PNG. The sheet uses a grid of 64×64 tiles with 9 walk frames per direction row.
4.2 Asset Directory
Create src/assets/ and place your spritesheet there (e.g., male_spritesheet.png).
Tip: The standard Bevy convention is an
assets/folder at the project root, notsrc/assets/. The tutorial overrides this viaAssetPlugin. For new projects, prefer the defaultassets/root to avoid configuration.
4.3 Refactor - Separate into Modules
src/main.rs:
use bevy::prelude::*;
mod player;
use crate::player::PlayerPlugin;
fn main() {
App::new()
.insert_resource(ClearColor(Color::WHITE))
.add_plugins(
DefaultPlugins.set(AssetPlugin {
file_path: "src/assets".into(),
..default()
}),
)
.add_systems(Startup, setup_camera)
.add_plugins(PlayerPlugin)
.run();
}
fn setup_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
insert_resource(ClearColor(Color::WHITE))- global background color.mod player;pulls insrc/player.rsas a module.
4.4 The Player Module (src/player.rs)
Constants
TILE_SIZE is 64 to match the LPC spritesheet’s 64×64 pixel grid. WALK_FRAMES is 9 because the LPC convention uses 9 frames per walk cycle row. ANIM_DT at 0.1 seconds per frame gives roughly 10fps animation playback, which looks natural for pixel art walking.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
const TILE_SIZE: u32 = 64;
const WALK_FRAMES: usize = 9;
const MOVE_SPEED: f32 = 140.0;
const ANIM_DT: f32 = 0.1; // ~10 FPS animation
}
Components
#![allow(unused)]
fn main() {
#[derive(Component)]
struct Player;
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
enum Facing {
Up,
Left,
Down,
Right,
}
#[derive(Component, Deref, DerefMut)]
struct AnimationTimer(Timer);
#[derive(Component)]
struct AnimationState {
facing: Facing,
moving: bool,
was_moving: bool,
}
}
Key Rust concepts:
enumvsstruct: Useenumwhen you pick one option from a set (direction, weapon type, game state). Usestructwhen you need multiple fields together (position with x/y, stats with health/mana).Deref/DerefMut: LetsAnimationTimertransparently behave like the innerTimer- you can call.tick(),.reset(), etc. directly on it.Clone+Copy: Rust moves values by default (original becomes invalid).Copyopts into cheap bitwise duplication for small types.PartialEq/Eqenable==comparisons.
Spawn System
#![allow(unused)]
fn main() {
fn spawn_player(
mut commands: Commands,
asset_server: Res<AssetServer>,
mut atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
let texture = asset_server.load("male_spritesheet.png");
let layout = atlas_layouts.add(TextureAtlasLayout::from_grid(
UVec2::splat(TILE_SIZE),
WALK_FRAMES as u32,
12,
None,
None,
));
let facing = Facing::Down;
let start_index = atlas_index_for(facing, 0);
commands.spawn((
Sprite::from_atlas_image(
texture,
TextureAtlas { layout, index: start_index },
),
Transform::from_translation(Vec3::ZERO),
Player,
AnimationState { facing, moving: false, was_moving: false },
AnimationTimer(Timer::from_seconds(ANIM_DT, TimerMode::Repeating)),
));
}
}
Important details:
AssetServerperforms lazy/async loading -load()returns a handle immediately. The actual file loads in the background. Multiple entities can share the same handle without duplicating memory.TextureAtlasLayout::from_grid(...)tells Bevy how to slice the spritesheet into individual frames.ResMut<Assets<TextureAtlasLayout>>- mutable access to Bevy’s asset storage to register our layout.TimerMode::Repeating- the timer automatically resets and fires again after each interval.
Movement System (Updated for Animation)
#![allow(unused)]
fn main() {
fn move_player(
input: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
mut player: Query<(&mut Transform, &mut AnimationState), With<Player>>,
) {
let Ok((mut transform, mut anim)) = player.single_mut() else {
return;
};
let mut direction = Vec2::ZERO;
if input.pressed(KeyCode::ArrowLeft) { direction.x -= 1.0; }
if input.pressed(KeyCode::ArrowRight) { direction.x += 1.0; }
if input.pressed(KeyCode::ArrowUp) { direction.y += 1.0; }
if input.pressed(KeyCode::ArrowDown) { direction.y -= 1.0; }
if direction != Vec2::ZERO {
let delta = direction.normalize() * MOVE_SPEED * time.delta_secs();
transform.translation.x += delta.x;
transform.translation.y += delta.y;
anim.moving = true;
// Dominant axis determines facing
if direction.x.abs() > direction.y.abs() {
anim.facing = if direction.x > 0.0 { Facing::Right } else { Facing::Left };
} else {
anim.facing = if direction.y > 0.0 { Facing::Up } else { Facing::Down };
}
} else {
anim.moving = false;
}
}
}
Tip: The
let Ok(...) = ... else { return; }pattern is let-else syntax (Rust 1.65+). It’s the cleanest way to early-return on query failure.
Animation System
#![allow(unused)]
fn main() {
fn animate_player(
time: Res<Time>,
mut query: Query<(&mut AnimationState, &mut AnimationTimer, &mut Sprite), With<Player>>,
) {
let Ok((mut anim, mut timer, mut sprite)) = query.single_mut() else {
return;
};
let atlas = match sprite.texture_atlas.as_mut() {
Some(a) => a,
None => return,
};
let target_row = row_zero_based(anim.facing);
let mut current_col = atlas.index % WALK_FRAMES;
let mut current_row = atlas.index / WALK_FRAMES;
// Snap to correct row on direction change
if current_row != target_row {
atlas.index = row_start_index(anim.facing);
current_col = 0;
current_row = target_row;
timer.reset();
}
let just_started = anim.moving && !anim.was_moving;
let just_stopped = !anim.moving && anim.was_moving;
if anim.moving {
if just_started {
let row_start = row_start_index(anim.facing);
let next_col = (current_col + 1) % WALK_FRAMES;
atlas.index = row_start + next_col;
timer.reset();
} else {
timer.tick(time.delta());
if timer.just_finished() {
let row_start = row_start_index(anim.facing);
let next_col = (current_col + 1) % WALK_FRAMES;
atlas.index = row_start + next_col;
}
}
} else if just_stopped {
timer.reset();
}
anim.was_moving = anim.moving;
}
}
Animation logic breakdown:
- Detect if the facing direction changed → snap to the new row’s first frame.
- If the player just started moving → immediately advance one frame for responsive feedback.
- If continuously moving → advance frames on timer ticks (~10 FPS).
- If just stopped → freeze on current frame, reset timer.
Helper Functions
#![allow(unused)]
fn main() {
fn row_start_index(facing: Facing) -> usize {
row_zero_based(facing) * WALK_FRAMES
}
fn atlas_index_for(facing: Facing, frame_in_row: usize) -> usize {
row_start_index(facing) + frame_in_row.min(WALK_FRAMES - 1)
}
fn row_zero_based(facing: Facing) -> usize {
match facing {
Facing::Up => 8,
Facing::Left => 9,
Facing::Down => 10,
Facing::Right => 11,
}
}
}
Note: These row indices (8–11) are specific to the LPC spritesheet layout where walk animations are in rows 8–11. Different spritesheets will have different layouts.
Player Plugin
#![allow(unused)]
fn main() {
pub struct PlayerPlugin;
impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, spawn_player)
.add_systems(Update, (move_player, animate_player));
}
}
}
- A trait is a contract (interface).
Pluginrequires abuildmethod. (move_player, animate_player)- passing a tuple registers multiple systems in the same schedule. They run in parallel by default unless they have conflicting data access.
Tip: Use
.chain()to enforce ordering:.add_systems(Update, (move_player, animate_player).chain())guaranteesmove_playerruns beforeanimate_playerevery frame.
5. Rust Concepts Quick Reference
| Concept | What It Means | When to Use |
|---|---|---|
mut | Mutable binding - value can be changed | Anytime you need to modify a variable |
struct | Group of named fields | Position, stats, config data |
enum | One-of-many variants | Directions, states, weapon types |
derive macro | Auto-generate trait implementations | Component, Debug, Clone, Copy, PartialEq |
Deref / DerefMut | Transparent wrapper - inner type’s methods available directly | Newtype pattern (e.g., AnimationTimer(Timer)) |
| Ownership & Move | Values have one owner; assignment transfers ownership | Default Rust behavior; use Copy/Clone to opt out |
Result<T, E> | Success (Ok) or failure (Err) | Fallible operations, query results |
Option<T> | Value present (Some) or absent (None) | Nullable fields, optional returns |
let ... else | Destructure or early-return | Clean guard clauses |
match | Exhaustive pattern matching | Enums, Options, Results |
..default() | Fill remaining fields with defaults | Struct initialization shorthand |
6. Bevy Memory & Performance Notes
Bevy’s ECS stores components in archetypes - tightly packed arrays of the same component types. This means:
Vec2in Rust = exactly 8 bytes (twof32). In JS/Python, the same concept uses ~48+ bytes due to runtime metadata.- With 1,000 entities: Rust uses ~8 KB vs ~48 KB+ in dynamic languages.
- Tight packing = better CPU cache utilization = faster frame rates.
- Type checking happens at compile time, not runtime - zero overhead.
7. Recommended Crates for Expanding This Project
Physics & Collision
| Crate | Purpose | Notes |
|---|---|---|
avian2d | 2D physics engine for Bevy | The modern standard; rigid bodies, colliders, joints. Replaces bevy_rapier2d as the go-to. |
bevy_rapier2d | Rapier physics integration | Battle-tested, still widely used. Slightly more complex API than Avian. |
Tilemap & World Building
| Crate | Purpose | Notes |
|---|---|---|
bevy_ecs_tilemap | Performant ECS-native tilemaps | Great for large worlds; each tile is an entity. |
bevy_ecs_ldtk | LDtk level editor integration | Visual level design with auto-spawning of entities. |
bevy_tiled | Tiled map editor integration | If you prefer Tiled over LDtk. |
Animation & VFX
| Crate | Purpose | Notes |
|---|---|---|
bevy_spritesheet_animation | Declarative sprite animations | Cleaner API than manual atlas cycling; supports animation clips and transitions. |
bevy_hanabi | GPU particle effects | Dust trails, explosions, magic effects. |
bevy_tweening | Tweening / easing animations | Smooth UI transitions, camera movements, entity interpolation. |
Input
| Crate | Purpose | Notes |
|---|---|---|
leafwing-input-manager | Action-based input mapping | Maps physical keys to game actions. Supports gamepads, rebinding, chords. Essential for any serious game. |
Camera
| Crate | Purpose | Notes |
|---|---|---|
bevy_pancam | Pan/zoom 2D camera | Quick setup for scrollable worlds. |
bevy_pixel_camera | Pixel-perfect rendering | Prevents sub-pixel blurriness in pixel art games. |
UI & Debug
| Crate | Purpose | Notes |
|---|---|---|
bevy-inspector-egui | Runtime entity/resource inspector | Invaluable for debugging - inspect and modify components live. |
bevy_egui | egui integration | Quick debug UIs, settings panels, dev tools. |
iyes_perf_ui | FPS/diagnostics overlay | Frame time, entity count, system timing. |
Audio
| Crate | Purpose | Notes |
|---|---|---|
bevy_kira_audio | Advanced audio with Kira backend | Spatial audio, crossfading, audio buses. Much richer than Bevy’s built-in audio. |
State Management & AI
| Crate | Purpose | Notes |
|---|---|---|
big-brain | Utility AI for NPCs | Score-based decision making; great for enemy behaviors. |
bonsai-bt | Behavior trees | Classic game AI pattern for complex NPC logic. |
seldom_state | State machine for entities | Clean state transitions for player/enemy states. |
Networking
| Crate | Purpose | Notes |
|---|---|---|
lightyear | Client/server netcode for Bevy | Prediction, interpolation, lag compensation. The most complete Bevy networking solution. |
Saving & Serialization
| Crate | Purpose | Notes |
|---|---|---|
bevy_save | Save/load game state | Snapshots, rollbacks, scene serialization. |
8. Tips for Further Improvement
Immediate Next Steps
- Add WASD support alongside arrow keys - check for both
KeyCode::KeyWandKeyCode::ArrowUpin your input system for better ergonomics. - Implement camera follow - instead of a static camera, make it track the player’s
Transformwith optional smoothing vialerp. - Add a movement speed component - replace the hardcoded
MOVE_SPEEDconstant with aMoveSpeed(f32)component on the player entity. This lets different entities have different speeds. - Sprint mechanic - check
input.pressed(KeyCode::ShiftLeft)and multiply speed by a factor.
Architecture Tips
- Use
Statesfor game flow - Bevy’s built-inStatesenum (e.g.,MainMenu,InGame,Paused) controls which systems run when. Add systems with.run_if(in_state(GameState::InGame)). - Use Events for communication - instead of checking flags every frame, use
EventWriter<T>andEventReader<T>for things like “player attacked”, “item picked up”, “enemy died”. - Organize by feature, not file type - instead of
components.rs,systems.rs, preferplayer.rs,enemy.rs,combat.rseach containing their own components + systems + plugin. - System ordering matters - if your animation looks jittery, chain your systems:
(move_player, animate_player).chain()or use explicit ordering with.before()/.after().
Performance Tips
- Use
SpriteBundletransforms wisely - avoid changingTransform.scalefor sprite resizing; use the sprite’scustom_sizefield instead. - Batch spawn with
commands.spawn_batch()- when spawning many entities (enemies, particles), batch spawning is significantly faster. - Run Bevy’s diagnostics - add
LogDiagnosticsPluginandFrameTimeDiagnosticsPluginto track frame times during development.
Debug Workflow
- Hot reloading assets - Bevy supports watching the asset directory for changes. Add
AssetPlugin { watch_for_changes: true, .. }during development to see sprite changes without restarting. - Use
info!(),warn!(),error!()- Bevy re-exportstracingmacros. Prefer these overprintln!()for structured, filterable logs.
9. Complete File Structure
bevy_game/
├── Cargo.toml
├── .cargo/
│ └── config.toml # Linker optimizations
├── src/
│ ├── main.rs # App setup, camera, plugin registration
│ ├── player.rs # Player components, systems, plugin
│ └── assets/
│ └── male_spritesheet.png
10. Full Schedule Reference
Startup → Runs once before first frame
PreUpdate → Bevy internals (input collection, events)
Update → Your game logic (movement, AI, gameplay)
PostUpdate → Bevy internals (transform propagation, rendering prep)
FixedUpdate → Fixed timestep (physics, deterministic logic)
Tip: Use
FixedUpdatefor physics and anything that needs deterministic behavior. UseUpdatefor input handling and visual updates.
API Quick Reference
APIs introduced in this chapter. See the API Glossary for all APIs across the book.
| API | Category | Description |
|---|---|---|
App | Core ECS | Application builder and entry point |
Commands | Core ECS | Deferred command queue for spawning/despawning entities |
Component | Core ECS | Derive macro to mark a type as attachable to entities |
Plugin | Core ECS | Trait for modular code organization |
Query<T> | Core ECS | System parameter for reading/writing entity components |
Res<T> | Core ECS | Immutable access to a global resource |
ResMut<T> | Core ECS | Mutable access to a global resource |
Resource | Core ECS | Derive macro for global singleton data |
Single<T> | Core ECS | Query that expects exactly one matching entity |
Startup | Core ECS | Schedule that runs systems once at launch |
Update | Core ECS | Schedule that runs systems every frame |
With<T> | Core ECS | Query filter requiring a component’s presence |
Camera2d | Rendering | Marker component for 2D camera entities |
Color | Rendering | Color representation (multiple color spaces) |
Sprite | Rendering | 2D image rendering component |
Text2d | Rendering | 2D text rendering component |
TextColor | Rendering | Text color component |
TextFont | Rendering | Font configuration for text rendering |
TextureAtlas | Rendering | Sprite atlas frame selection |
TextureAtlasLayout | Rendering | Defines how a spritesheet is sliced |
Transform | Math | Position, rotation, and scale of an entity |
UVec2 | Math | Unsigned integer 2D vector |
Vec2 | Math | 2D floating-point vector |
Vec3 | Math | 3D floating-point vector |
AssetServer | Assets | Loads and manages game assets |
Assets<T> | Assets | Typed storage for loaded assets |
Handle<T> | Assets | Reference-counted pointer to a loaded asset |
ButtonInput<KeyCode> | Input | Keyboard state resource |
KeyCode | Input | Identifies a physical keyboard key |
Time | Time | Frame timing and delta time resource |
Timer | Time | Countdown or repeating timer |
TimerMode | Time | Once or Repeating timer behavior |
AssetPlugin | Plugins | Configures asset loading paths |
ClearColor | Plugins | Global background color resource |
DefaultPlugins | Plugins | Standard plugin group |
ImagePlugin | Plugins | Configures image loading defaults |
Window | Plugins | Window configuration |
WindowPlugin | Plugins | Configures window creation |
Clone | Rust std | Deep-copy capability |
Copy | Rust std | Cheap bitwise duplication |
Debug | Rust std | Debug formatting ({:?}) |
Default | Rust std | Default value generation |
Deref | Rust std | Transparent wrapper delegation |
DerefMut | Rust std | Mutable wrapper delegation |
Eq | Rust std | Total equality |
Option<T> | Rust std | Optional value (Some or None) |
PartialEq | Rust std | Equality comparison (==) |
Result<T, E> | Rust std | Success or failure return type |
Vec<T> | Rust std | Growable heap-allocated array |
Bevy & Rust Game Development - Chapter 2 Reference
1. Chapter Overview
This chapter builds a procedurally generated tilemap world using the Wave Function Collapse (WFC) algorithm. By the end you have layered terrain (dirt → grass → yellow grass → water → props) with smooth transitions, all generated at runtime from a set of socket-based compatibility rules.
What Gets Built
| Layer | Z-Level | Purpose | Weight |
|---|---|---|---|
| Dirt | 0 | Base foundation - fills everything | 20.0 |
| Green Grass | 1 | Patches on top of dirt with smooth edges | 5.0 |
| Yellow Grass | 2 | Patches on top of green grass | 5.0 |
| Water | 3 | Lakes/ponds with shoreline transitions | 0.02–0.2 |
| Props | 4 | Trees, rocks, plants (land-only) | 0.008–0.025 |
2. Wave Function Collapse (WFC) - How It Works
WFC is a constraint-solving algorithm analogous to Sudoku. The core loop:
- Find the most constrained cell - the grid position with the fewest valid tile options remaining.
- Collapse it - pick one tile (weighted random selection).
- Propagate constraints - update all neighbors to remove now-invalid options.
- Repeat until the grid is full or a contradiction occurs.
- On contradiction - restart with a new random seed (no backtracking in this implementation).
Key Properties
- Deterministic per seed - same seed produces the same world every time.
- Weight-driven - higher-weight tiles appear more frequently. Tweaking a single weight value drastically changes the landscape.
- No large-scale structure - WFC is local. It won’t create “one big lake” or “a mountain range” by default. You need post-processing or pre-seeding for that.
- Layer separation simplifies rules - instead of one massive ruleset, each terrain type gets its own layer with independent horizontal rules and vertical connections to layers above/below.
Controlling Randomness
| Technique | Effect |
|---|---|
Same seed (RngMode::Seeded(42)) | Reproducible worlds for testing/sharing |
Random seed (RngMode::RandomSeed) | Unique world each run |
| Tile weights | Bias frequency (e.g., WATER_WEIGHT = 0.02 → small ponds; 0.07 → large lakes) |
| Grid wrapping | (true, true, false) → Pac-Man-style world wrap |
WFC Limitations to Be Aware Of
- No global structure control without pre-seeding or post-processing.
- Complex rules increase computation time and failure rate.
- Poorly designed socket rules produce broken or unrealistic landscapes.
- Performance degrades with very large grids - chunking strategies needed (covered in later chapters).
3. Project Setup
Dependencies
The bevy_procedural_tilemaps crate provides a WFC constraint solver, 3D Cartesian grid types, and a sprite spawner that automatically places tiles as the grid is solved. It handles all the generation plumbing so you only need to define rules and assets.
[package]
name = "bevy_game"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.18"
bevy_procedural_tilemaps = "0.2.0"
Note:
bevy_procedural_tilemapsis a fork ofghx_proc_genmaintained for Bevy 0.18 compatibility. For 3D support and debug tools, check the originalghx_proc_gen.
File Structure
Data flows through these files as a pipeline: tilemap.rs defines sprite coordinates → assets.rs loads the atlas and creates renderable sprites → sockets.rs defines how tiles connect → rules.rs builds WFC models from sockets → generate.rs configures the grid and launches the solver.
src/
├── main.rs
├── player.rs
└── map/
├── mod.rs # Module declarations
├── assets.rs # SpawnableAsset, atlas loading, sprite conversion
├── tilemap.rs # Sprite atlas coordinates & definitions
├── models.rs # TerrainModelBuilder - keeps models & assets synced
├── sockets.rs # Socket type definitions for each layer
├── rules.rs # WFC rules per layer + build_world()
└── generate.rs # Grid config, generator setup, spawning
Asset Structure
src/assets/
├── male_spritesheet.png # Player (from Ch1)
└── tile_layers/
└── tilemap.png # 256×320 sprite atlas, 32×32 tiles
Tip: The tilemap assets are based on 16x16 Game Assets by George Bailey (CC-BY 4.0), upscaled to 32×32.
4. Architecture Overview
The system flows through five stages:
tilemap.rs → Defines sprite names + pixel coordinates in the atlas
↓
assets.rs → Loads atlas, creates TilemapHandles, converts names → Sprites
↓
sockets.rs → Defines socket types per layer (dirt, grass, water, props)
↓
rules.rs → Builds models with socket patterns + connection rules
↓
generate.rs → Configures WFC generator, spawns the map entity
The Socket System
Every tile model exposes 6 sockets (one per face in a 3D Cartesian grid):
| Direction | Axis | Meaning in 2D |
|---|---|---|
x_pos | Right | Tile to the right |
x_neg | Left | Tile to the left |
y_pos | Up | Tile above |
y_neg | Down | Tile below |
z_pos | Top layer | What sits on top of this tile (layer above) |
z_neg | Bottom layer | What this tile sits on (layer below) |
Two tiles can be placed adjacent if their touching sockets are declared compatible via socket_collection.add_connections(...).
5. Step-by-Step Implementation
Step 1 - Tilemap Definition (tilemap.rs)
Defines the sprite atlas layout and provides lookup methods. Each entry maps a human-readable sprite name to its pixel coordinates in the atlas PNG, all resolved at compile time as const data with zero runtime overhead.
#![allow(unused)]
fn main() {
pub struct TilemapSprite {
pub name: &'static str,
pub pixel_x: u32,
pub pixel_y: u32,
}
pub struct TilemapDefinition {
pub tile_width: u32,
pub tile_height: u32,
pub atlas_width: u32,
pub atlas_height: u32,
pub sprites: &'static [TilemapSprite],
}
pub const TILEMAP: TilemapDefinition = TilemapDefinition {
tile_width: 32,
tile_height: 32,
atlas_width: 256,
atlas_height: 320,
sprites: &[
TilemapSprite { name: "dirt", pixel_x: 128, pixel_y: 0 },
// ... all grass, water, prop sprites
],
};
}
Key methods: sprite_index(name) → Option<usize>, sprite_rect(index) → URect.
Tip: All sprite data is
const- resolved at compile time, zero runtime overhead.
Step 2 - Asset Loading (assets.rs)
SpawnableAsset
#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct SpawnableAsset {
sprite_name: &'static str,
grid_offset: GridDelta, // Multi-tile offset (e.g., tree top = (0,1,0))
offset: Vec3, // Fine pixel positioning
components_spawner: fn(&mut EntityCommands), // Custom component injection
}
}
Builder pattern: SpawnableAsset::new("dirt") or SpawnableAsset::new("big_tree_tl").with_grid_offset(GridDelta::new(0, 1, 0)).
TilemapHandles
Holds Handle<Image> and Handle<TextureAtlasLayout> after atlas loading. The .sprite(index) method creates a Sprite ready for rendering.
Loading Pipeline
prepare_tilemap_handles(...)- loads the atlas PNG, registers each sprite’s rect in the layout.load_assets(...)- convertsVec<Vec<SpawnableAsset>>intoModelsAssets<Sprite>that the generator consumes.
Step 3 - Socket Definitions (sockets.rs)
Each layer gets its own socket struct. The material socket enforces same-type adjacency horizontally, while layer_up/layer_down control vertical stacking between layers. Transition sockets like void_and_grass/grass_and_void create smooth curved edges where terrain meets empty space.
#![allow(unused)]
fn main() {
pub struct DirtLayerSockets {
pub layer_up: Socket,
pub layer_down: Socket,
pub material: Socket,
}
pub struct GrassLayerSockets {
pub layer_up: Socket,
pub layer_down: Socket,
pub material: Socket,
pub void_and_grass: Socket, // Transition: empty → grass
pub grass_and_void: Socket, // Transition: grass → empty
pub grass_fill_up: Socket, // Allows yellow grass to stack
}
// Yellow grass: 3 sockets (reuses grass edge sockets horizontally)
// Water: 6 sockets (similar pattern to grass)
// Props: 5 sockets (includes big_tree base connections)
}
All sockets are created via socket_collection.create() in create_sockets().
Why transition sockets? A pair like void_and_grass / grass_and_void ensures edge tiles only connect where one side is empty and the other is filled. This creates smooth, curved borders instead of hard blocky edges.
Step 4 - Model Builder (models.rs)
TerrainModelBuilder keeps model indices and asset indices in lockstep - when the WFC solver collapses a cell to model index N, the renderer looks up asset index N to know which sprite to display. Without this synchronization, tiles would render with the wrong sprites.
#![allow(unused)]
fn main() {
pub struct TerrainModelBuilder {
pub models: ModelCollection<Cartesian3D>,
pub assets: Vec<Vec<SpawnableAsset>>,
}
}
create_model(template, assets) adds a model and its sprites at the same index - keeping them synchronized. into_parts() splits them for the generator and renderer.
Important Rust concept - Generics & Trait Bounds:
#![allow(unused)] fn main() { pub fn create_model<T>(&mut self, template: T, assets: Vec<SpawnableAsset>) where T: Into<ModelTemplate<Cartesian3D>> }
Tcan beSocketsCartesian3D::Simple,SocketsCartesian3D::Multiple, or aModelTemplate- anything that converts into a model template.
Step 5 - Rules (rules.rs)
Each layer follows the same pattern:
- Create a void model - empty space (no sprite) with
voidsockets horizontally. - Create the center tile model -
materialsockets on all horizontal sides. - Create edge templates - outer corners, inner corners, side edges.
- Rotate templates -
Rot90,Rot180,Rot270aroundDirection::ZForwardto get all 4 orientations from 1 definition. - Define connection rules - which sockets can connect to which.
- Define layer connections -
add_rotated_connection()for vertical links.
Template Rotation Pattern
Define one socket layout, rotate it to create 4 variants:
#![allow(unused)]
fn main() {
let corner_out = SocketsCartesian3D::Simple { /* sockets */ }.to_template();
builder.create_model(corner_out.clone(), vec![sprite("corner_out_tl")]);
builder.create_model(corner_out.rotated(Rot90, ZForward), vec![sprite("corner_out_bl")]);
builder.create_model(corner_out.rotated(Rot180, ZForward), vec![sprite("corner_out_br")]);
builder.create_model(corner_out.rotated(Rot270, ZForward), vec![sprite("corner_out_tr")]);
}
Key insight: Rotation shifts the socket pattern, not the sprite. Each rotation gets paired with a different sprite that visually matches the rotated sockets.
Layer-Specific Notes
| Layer | Special Behavior |
|---|---|
| Dirt | Single model, weight 20.0. material ↔ material only. |
| Green Grass | 3 connection rules create organic patches. Uses SocketsCartesian3D::Multiple for z_pos to support both layer_up and grass_fill_up. |
| Yellow Grass | Reuses green grass’s material, void_and_grass, grass_and_void for horizontal connections. Only defines vertical sockets of its own. |
| Water | Very low weight (0.02) for small ponds. Void model uses Multiple sockets with both layer_up and ground_up to support props above. |
| Props | No rotation needed. Multi-tile trees use GridDelta offsets and dedicated base sockets. props_down ↔ water.ground_up ensures land-only placement. |
The build_world() Function
Orchestrates everything: it creates all sockets, builds every layer’s tile models and their connection rules, then returns the three inputs the WFC generator needs - spawnable assets, the model collection, and the socket collection.
#![allow(unused)]
fn main() {
pub fn build_world() -> (Vec<Vec<SpawnableAsset>>, ModelCollection<Cartesian3D>, SocketCollection) {
let mut socket_collection = SocketCollection::new();
let sockets = create_sockets(&mut socket_collection);
let mut builder = TerrainModelBuilder::new();
build_dirt_layer(&mut builder, &sockets, &mut socket_collection);
build_grass_layer(&mut builder, &sockets, &mut socket_collection);
build_yellow_grass_layer(&mut builder, &sockets, &mut socket_collection);
build_water_layer(&mut builder, &sockets, &mut socket_collection);
build_props_layer(&mut builder, &sockets, &mut socket_collection);
let (assets, models) = builder.into_parts();
(assets, models, socket_collection)
}
}
Step 6 - Generator Configuration (generate.rs)
The grid is 25×18 tiles at 32px each, producing an 800×576 pixel map that fits a standard window. GRID_Z is 5 because there are five terrain layers stacked vertically: dirt, grass, yellow grass, water, and props.
#![allow(unused)]
fn main() {
pub const GRID_X: u32 = 25;
pub const GRID_Y: u32 = 18;
pub const TILE_SIZE: f32 = 32.;
const GRID_Z: u32 = 5; // dirt + grass + yellow_grass + water + props
}
The setup_generator system:
- Calls
build_world()to get assets, models, and socket rules. - Builds WFC
RuleswithDirection::ZForwardrotation axis (2D game). - Creates a
CartesianGridwith dimensions(25, 18, 5)and no wrapping. - Configures the generator:
MinimumRemainingValuenode heuristic +WeightedProbabilitymodel selection. - Loads the sprite atlas and converts to renderable assets.
- Spawns the generator entity centered on screen with
NodesSpawner.
The Transform offsets by half the map size so the grid is centered on screen (origin at the middle rather than the bottom-left). NodesSpawner is the bridge between the WFC solver and rendering - as each cell is collapsed, it spawns the corresponding sprite entity at the correct position.
#![allow(unused)]
fn main() {
commands.spawn((
Transform::from_translation(Vec3 {
x: -TILE_SIZE * grid.size_x() as f32 / 2.,
y: -TILE_SIZE * grid.size_y() as f32 / 2.,
z: 0.,
}),
grid,
generator,
NodesSpawner::new(models_assets, NODE_SIZE, ASSETS_SCALE)
.with_z_offset_from_y(true), // Y-based depth sorting
));
}
with_z_offset_from_y(true)- tiles higher on screen render in front, giving natural depth ordering without manual z-sorting.
Step 7 - Main Integration (main.rs)
ProcGenSimplePlugin registers the WFC solver and tile-spawning systems so the generator entity is processed each frame until the grid is fully collapsed. ImagePlugin::default_nearest() disables texture filtering, keeping pixel art tiles crisp instead of blurry.
mod map;
mod player;
fn main() {
let map_size = map_pixel_dimensions(); // 800×576 for 25×18 grid
App::new()
.insert_resource(ClearColor(Color::WHITE))
.add_plugins(
DefaultPlugins
.set(AssetPlugin { file_path: "src/assets".into(), ..default() })
.set(WindowPlugin {
primary_window: Some(Window {
resolution: WindowResolution::new(map_size.x as u32, map_size.y as u32),
resizable: false,
..default()
}),
..default()
})
.set(ImagePlugin::default_nearest()), // Crisp pixel art
)
.add_plugins(ProcGenSimplePlugin::<Cartesian3D, Sprite>::default())
.add_systems(Startup, (setup_camera, setup_generator))
.add_plugins(PlayerPlugin)
.run();
}
Player Z-Order Fix
The player must render above all map layers. The 0.8 scale shrinks the 64px LPC character to roughly match the 32px tile size, and PLAYER_Z at 20.0 places the player above all five terrain layers (Z 0–4) so they’re never hidden behind tiles.
#![allow(unused)]
fn main() {
// player.rs
const PLAYER_Z: f32 = 20.0;
// In spawn_player:
Transform::from_translation(Vec3::new(0., 0., PLAYER_Z)).with_scale(Vec3::splat(0.8)),
}
6. Rust Concepts Introduced in This Chapter
| Concept | What It Means | Example |
|---|---|---|
&'static str | Reference to a string literal that lives for the entire program | Sprite names like "dirt" baked into the binary |
Lifetimes ('a) | Rust tracks how long references are valid to prevent use-after-free | 'static = lives forever, 'a = lives as long as its scope |
| Closures | Anonymous functions that capture environment variables | |_| {} (no-op), || socket_collection.create() |
Generics (<T>) | Type parameter - one function works with multiple types | create_model<T> accepts any socket format |
Trait bounds (where T: Into<...>) | Constrain generic types to specific capabilities | Ensures T can convert into ModelTemplate |
str vs String | &str = borrowed view of text; String = owned, heap-allocated | Use &str for constants, String for dynamic text |
fn(...) vs closures | fn(...) is a function pointer (no captures); closures can capture | components_spawner: fn(&mut EntityCommands) |
| Implicit returns | Last expression without ; is the return value | self at end of with_grid_offset() |
| Encapsulation | Private fields (no pub) accessed only through public methods | GridDelta fields are private; use with_grid_offset() |
7. Quick Weight Tuning Guide
Weights control tile frequency. Small changes produce dramatic results:
| Weight Value | Effect |
|---|---|
WATER_WEIGHT = 0.02 | Small scattered ponds |
WATER_WEIGHT = 0.07 | Large lake coverage |
WATER_WEIGHT = 0.15 | Mostly water with land islands |
dirt.with_weight(20.) | Dirt dominates base layer (desired) |
grass.with_weight(5.) | Moderate grass coverage |
PROPS_WEIGHT = 0.025 | Sparse prop placement |
ROCKS_WEIGHT = 0.008 | Rocks rarer than plants |
Tip: Start with very low weights (0.01–0.05) for new terrain types and increase gradually. High weights in complex rulesets can cause WFC contradictions.
8. Known Issues & Workarounds
| Issue | Workaround |
|---|---|
| White lines between tiles (Linux/Wayland) | Run with WINIT_UNIX_BACKEND=x11 WAYLAND_DISPLAY= cargo run |
| Player walks on water | Collision detection not yet implemented (coming in later chapters) |
| WFC fails on large grids | Keep grid ≤ ~50×50 for now; chunking strategies covered later |
| Sprite blurriness | Ensure ImagePlugin::default_nearest() is set (not linear filtering) |
9. Recommended Crates for Expanding Procedural Worlds
Alternative/Complementary Proc-Gen
| Crate | Purpose | Notes |
|---|---|---|
ghx_proc_gen | Original WFC library | 3D support, debug visualization, more advanced features than the fork |
noise | Perlin/Simplex noise generation | Great for height maps, temperature maps, biome distribution pre-seeding |
bracket-noise | Game-focused noise library | Part of the bracket-lib ecosystem; cellular automata, Perlin, etc. |
bracket-pathfinding | A* and Dijkstra pathfinding | Essential for NPC navigation on generated terrain |
Tilemap Rendering (Alternatives to Raw Sprites)
| Crate | Purpose | Notes |
|---|---|---|
bevy_ecs_tilemap | High-performance ECS-native tilemaps | Renders thousands of tiles in a single draw call; much faster than individual sprite entities for large maps |
bevy_ecs_ldtk | LDtk level editor integration | Hand-design parts of the world, proc-gen the rest |
Collision & Physics (Next Steps)
| Crate | Purpose | Notes |
|---|---|---|
avian2d | 2D physics for Bevy | Add colliders to water tiles and props to prevent player walkthrough |
bevy_rapier2d | Rapier physics integration | Battle-tested alternative to Avian |
World Expansion
| Crate | Purpose | Notes |
|---|---|---|
bevy_save | Save/load game state | Persist generated worlds with their seeds |
rand | Random number generation | Seeded RNG for reproducible world generation outside of the WFC library |
bevy_asset_loader | Declarative asset loading | Loading states with progress bars for large atlas files |
Visual Polish
| Crate | Purpose | Notes |
|---|---|---|
bevy_hanabi | GPU particle effects | Water splashes, dust when walking, fireflies near trees |
bevy_tweening | Tweening/easing animations | Animate water tiles with gentle bob, tree sway |
bevy_pixel_camera | Pixel-perfect rendering | Prevents sub-pixel blurriness at any zoom level |
10. Tips for Further Improvement
Immediate Enhancements
- Pre-seed terrain with noise - Use Perlin/Simplex noise to create a height map, then bias WFC weights based on elevation. Low areas get higher water weight, high areas get more rocks.
- Add animated water - Use a simple system that cycles through 2–3 water sprite variants on a timer, similar to the player animation from Chapter 1.
- Camera follow + bounds clamping - Make the camera follow the player but clamp to map edges so you never see void outside the world.
- Collision layer - Tag water and prop entities with a
Collidercomponent. Add a system that prevents player movement into collider tiles.
Architecture Improvements
- Chunk-based generation - Instead of generating the entire map at startup, generate chunks around the player and despawn distant ones. This scales to infinite worlds.
- Biome system - Define biome presets (forest, desert, swamp) with different weight configurations. Use noise to assign biomes to regions, then generate each region with biome-specific weights.
- Separate render and logic layers - Use
bevy_ecs_tilemapfor rendering (single draw call) while keeping your WFC logic for generation. This dramatically improves performance for large maps. - Seed display/input - Show the current world seed on screen. Let the player input a seed to share worlds.
Performance Notes
- Entity count - A 25×18 grid with 5 layers spawns up to 2,250 entities. At 100×100×5 that’s 50,000 entities. Use
bevy_ecs_tilemapor sprite batching for anything beyond ~50×50. - Generation time - WFC runs synchronously in
setup_generator. For large grids, move generation to an async task and show a loading screen. - Debug tools - Add
bevy-inspector-eguito inspect tile entities at runtime. The originalghx_proc_genhas built-in debug visualization for WFC state.
11. Complete Layer Stack Visualization
Z=4 Props [trees, rocks, plants] ← only on ground_up (no water)
Z=3 Water [center, edges, corners] ← low weight, smooth shorelines
Z=2 Yellow [center, edges, corners] ← sits on grass_fill_up
Z=1 Grass [center, edges, corners] ← patches on dirt
Z=0 Dirt [single tile] ← covers everything
Each position (x, y) has one tile per Z-level. Void models (no sprite) fill empty slots. The WFC solves all layers simultaneously - a single constraint graph across the full 3D grid.
API Quick Reference
APIs introduced in this chapter. See the API Glossary for all APIs across the book.
| API | Category | Description |
|---|---|---|
URect | Math | Unsigned integer rectangle |
WindowResolution | Plugins | Window size configuration |
String | Rust std | Owned heap-allocated UTF-8 string |
Bevy & Rust Game Development - Chapter 3 Reference
1. Chapter Overview
This chapter replaces Chapter 1’s hardcoded player with a data-driven character system. All character attributes (speed, health, animations, spritesheets) are defined in a single .ron config file. The code is generic - one animation system, one movement system, one spawn system - that works with any character.
What Gets Built
- External
characters.ronfile defining 6 characters with unique stats and animations - Generic animation engine supporting Walk, Run, and Jump with directional spritesheets
- Runtime character switching via number keys (1–6)
- Async asset loading with a self-terminating initialization pattern
- Sprint mechanic (hold Shift) with per-character speed multipliers
Why Data-Driven Design
| Hardcoded (Ch1) | Data-Driven (Ch3) |
|---|---|
Constants per character (WARRIOR_SPEED, MAGE_SPEED) | Single CharacterEntry struct read from .ron |
Duplicate functions per character (animate_warrior, animate_mage) | One animate_characters system for all |
| Code change required to add/tweak characters | Edit characters.ron - no recompile needed |
| Bug fix = update N functions | Bug fix = update 1 function |
| No runtime switching | Swap character data at runtime instantly |
2. Project Setup
New Dependencies
[dependencies]
bevy = "0.18"
bevy_procedural_tilemaps = "0.2.0"
bevy_common_assets = { version = "0.15.0-rc.1", features = ["ron"] }
serde = { version = "1.0", features = ["derive"] }
bevy_common_assets- Asset loader plugins for common formats. Theronfeature enables loading.ronfiles as Bevy assets.serde- Serialization/deserialization with#[derive(Serialize, Deserialize)]for structs that map to the.ronfile.
File Structure
src/
├── main.rs
├── map/ # From Chapter 2
│ └── (unchanged)
└── characters/
├── mod.rs # CharactersPlugin definition
├── config.rs # Data types: AnimationType, CharacterEntry, CharactersList
├── animation.rs # Facing, AnimationClip, AnimationController, animate system
├── movement.rs # Player marker, input reading, move_player, jump handling
└── spawn.rs # Spawn, async init, character switching
Asset Structure
src/assets/
├── characters/
│ └── characters.ron # All character definitions
├── male_spritesheet.png
├── female_spritesheet.png
├── crimson_count_spritesheet.png
├── graveyard_reaper_spritesheet.png
├── lantern_warden_spritesheet.png
├── starlit_oracle_spritesheet.png
└── tile_layers/
└── tilemap.png # From Chapter 2
Important: Delete
src/player.rsfrom Chapter 1 and removemod player;/PlayerPluginfrommain.rs. The newcharactersmodule replaces it entirely.
3. The RON Configuration File
What Is RON?
RON (Rusty Object Notation) is a human-readable data format designed for Rust. Compared to JSON:
| Feature | JSON | RON |
|---|---|---|
| Key quotes | Required | Optional for identifiers |
| Comments | Not supported | // and /* */ supported |
| Trailing commas | Syntax error | Allowed |
| Rust types | Limited to JS types | Native tuples, structs, enums |
| Enum variants | Encoded as strings | First-class (Walk, Run, Jump) |
Character Schema
Every entry in characters.ron follows this structure:
(
characters: [
(
name: "Warrior",
max_health: 150.0,
base_move_speed: 140.0,
run_speed_multiplier: 1.8,
texture_path: "male_spritesheet.png",
tile_size: 64,
atlas_columns: 9,
animations: {
Walk: (
start_row: 8, // Row in the spritesheet (0-indexed from top)
frame_count: 9, // Number of frames in the animation
frame_time: 0.1, // Seconds per frame
directional: true, // true = 4 rows (Up/Left/Down/Right)
),
Run: (
start_row: 8,
frame_count: 9,
frame_time: 0.065,
directional: true,
),
Jump: (
start_row: 5,
frame_count: 7,
frame_time: 0.08,
directional: false, // Same row regardless of facing
),
},
),
// ... more characters
]
)
Key fields:
directional: true- spritesheet has 4 consecutive rows: Up (start_row+0), Left (+1), Down (+2), Right (+3).directional: false- single row used for all directions (e.g., Jump plays the same regardless of facing).atlas_columns- number of sprite columns in the sheet. Used to calculate frame index:row * columns + frame.
4. Architecture - System Flow
characters.ron ──load──► CharactersList (asset)
│
┌───────────┼───────────┐
▼ ▼ ▼
spawn_player initialize switch_character
(Startup) _player (Update, keys 1-9)
(Update,
self-terminating)
│
┌───────────┼───────────┐
▼ ▼ ▼
move_player update_jump animate_characters
(input → _state (timer tick →
Transform, (jump done frame advance)
AnimCtrl) → Walk)
│
▼
update_animation_flags
(was_moving = is_moving)
System Execution Order (all in Update)
initialize_player_character- adds components once asset is loaded (self-terminates)switch_character- swaps character data on digit key pressmove_player- reads input, updates Transform and AnimationControllerupdate_jump_state- detects jump animation completion, resets to Walkanimate_characters- ticks timer, advances sprite frame indexupdate_animation_flags- stores current frame’s state for next-frame change detection
5. Data Types (config.rs)
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum AnimationType {
Walk,
Run,
Jump,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnimationDefinition {
pub start_row: usize, // First row in the spritesheet
pub frame_count: usize, // Number of frames
pub frame_time: f32, // Seconds per frame
pub directional: bool, // 4-row directional or single-row
}
#[derive(Component, Asset, TypePath, Debug, Clone, Serialize, Deserialize)]
pub struct CharacterEntry {
pub name: String,
pub max_health: f32,
pub base_move_speed: f32,
pub run_speed_multiplier: f32,
pub texture_path: String,
pub tile_size: u32,
pub atlas_columns: usize,
pub animations: HashMap<AnimationType, AnimationDefinition>,
}
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize)]
pub struct CharactersList {
pub characters: Vec<CharacterEntry>,
}
}
CharacterEntry is both an Asset (loadable from disk) and a Component (attachable to entities). This dual nature means the loaded data is directly queryable in ECS systems.
calculate_max_animation_row()
Inspects all animation definitions to determine the total rows needed for the texture atlas. The atlas layout must know the highest row index used across all animations so it allocates enough rows when slicing the spritesheet into frames.
#![allow(unused)]
fn main() {
impl CharacterEntry {
pub fn calculate_max_animation_row(&self) -> usize {
self.animations.values()
.map(|def| if def.directional { def.start_row + 3 } else { def.start_row })
.max()
.unwrap_or(0)
}
}
}
Directional animations consume 4 rows (start_row through start_row+3), non-directional consume 1.
6. Animation Engine (animation.rs)
Facing - Direction Tracking
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Facing { Up, Left, Down, Right }
impl Facing {
pub fn from_direction(direction: Vec2) -> Self {
if direction.x.abs() > direction.y.abs() {
if direction.x > 0.0 { Facing::Right } else { Facing::Left }
} else {
if direction.y > 0.0 { Facing::Up } else { Facing::Down }
}
}
fn direction_index(self) -> usize {
match self { Up => 0, Left => 1, Down => 2, Right => 3 }
}
}
}
For diagonal movement, the dominant axis wins. direction_index() maps to the spritesheet row offset within a directional animation group.
AnimationClip - Frame Range Math
AnimationClip maps a spritesheet row to a range of atlas indices. new() calculates the first and last frame index from the row and column count, next() advances to the next frame and wraps back to the first for looping, and is_complete() checks if a one-shot animation (like Jump) has played its last frame.
#![allow(unused)]
fn main() {
pub struct AnimationClip {
first: usize, // First frame index in the atlas
last: usize, // Last frame index in the atlas
}
impl AnimationClip {
pub fn new(row: usize, frame_count: usize, atlas_columns: usize) -> Self {
let first = row * atlas_columns;
Self { first, last: first + frame_count - 1 }
}
pub fn start(self) -> usize { self.first }
pub fn contains(self, index: usize) -> bool { (self.first..=self.last).contains(&index) }
pub fn next(self, index: usize) -> usize {
if index >= self.last { self.first } else { index + 1 }
}
pub fn is_complete(self, current_index: usize, timer_finished: bool) -> bool {
current_index >= self.last && timer_finished
}
}
}
Example: Walk Down at start_row: 8, directional, 9 frames, 9 atlas columns.
- Actual row =
8 + Facing::Down.direction_index()=8 + 2=10 first=10 * 9=90last=90 + 9 - 1=98- Frames cycle: 90 → 91 → … → 98 → 90 (loop)
Components
These three components work together to drive animation: AnimationController tracks which animation clip is active and which direction the character faces, AnimationState stores current and previous-frame movement flags for change detection (e.g., detecting the moment a character starts or stops moving), and AnimationTimer controls frame playback rate.
#![allow(unused)]
fn main() {
#[derive(Component)]
pub struct AnimationController {
pub current_animation: AnimationType, // Walk, Run, or Jump
pub facing: Facing, // Up, Down, Left, Right
}
#[derive(Component, Default)]
pub struct AnimationState {
pub is_moving: bool,
pub was_moving: bool, // Previous frame's value
pub is_jumping: bool,
pub was_jumping: bool,
}
#[derive(Component, Deref, DerefMut)]
pub struct AnimationTimer(pub Timer);
}
State change detection: just_started_moving = is_moving && !was_moving. The update_animation_flags system copies current → previous at frame end.
Note: Boolean flags work for Walk/Run/Jump but don’t scale well. Chapter 4 introduces a proper state machine pattern.
The animate_characters System
Three branches per frame:
| Condition | Action |
|---|---|
| Animation changed (just started/stopped moving or jumping) | Reset to frame 0, update timer duration |
| Should animate (currently moving or jumping) | Tick timer, advance frame on completion |
| Idle (not moving, not jumping) | Snap to frame 0 (standing pose) |
Safety check: if the current atlas index is outside the clip’s range (e.g., after a character switch), reset to clip start.
7. Movement System (movement.rs)
Input Reading
This function uses an iterator chain to convert key presses into a movement vector: it filters the key-direction pairs to find which keys are currently pressed, extracts their direction vectors, and sums them. The result is a combined direction that naturally handles diagonal movement (e.g., Right + Up = Vec2(1.0, 1.0)).
#![allow(unused)]
fn main() {
fn read_movement_input(input: &ButtonInput<KeyCode>) -> Vec2 {
const MOVEMENT_KEYS: [(KeyCode, Vec2); 4] = [
(KeyCode::ArrowLeft, Vec2::NEG_X),
(KeyCode::ArrowRight, Vec2::X),
(KeyCode::ArrowUp, Vec2::Y),
(KeyCode::ArrowDown, Vec2::NEG_Y),
];
MOVEMENT_KEYS.iter()
.filter(|(key, _)| input.pressed(*key))
.map(|(_, dir)| *dir)
.sum()
}
}
Diagonal input sums to e.g. Vec2(1.0, 1.0), which gets .normalize()d in move_player so diagonal movement isn’t ~1.41× faster.
Controls Summary
| Key | Action |
|---|---|
| Arrow keys | Move in 4/8 directions |
| Shift (L or R) | Sprint (uses run_speed_multiplier from config) |
| Space | Jump (plays once, then returns to Walk) |
| 1–6 | Switch character |
move_player Logic
- Read direction from arrow keys.
- If Space just pressed → set
is_jumping, switch toAnimationType::Jump. - If direction ≠ zero: normalize, apply speed × delta_time, update Transform and Facing.
- If not jumping: set
is_moving = true, animation = Run (if Shift) or Walk. - If no input and not jumping:
is_moving = false, animation = Walk (idle pose).
update_jump_state
Monitors jump animation completion using AnimationClip::is_complete(). When the last frame plays and the timer finishes, resets is_jumping = false and switches back to AnimationType::Walk.
8. Spawn & Character Switching (spawn.rs)
Two-Stage Spawn Pattern
Bevy loads assets asynchronously. The pattern:
Stage 1 - spawn_player (Startup):
- Kicks off
asset_server.load("characters/characters.ron"). - Stores the
Handle<CharactersList>in a resource. - Spawns a bare entity:
Playermarker +Transform+ emptySprite.
Stage 2 - initialize_player_character (Update, self-terminating):
- Runs every frame but queries
(With<Player>, Without<AnimationController>). - Once the
.ronloads: grabs character data, loads texture, creates atlas layout, inserts all components. - After inserting
AnimationController, the entity no longer matches the query → system becomes a no-op.
Tip: This self-terminating query pattern is idiomatic Bevy for handling async asset dependencies. Use
Without<SomeMarker>to gate initialization, then insert that marker when done.
Character Switching
switch_character maps digit keys 1–9 to character indices. On press:
- Validates index is within
characters_list.characters.len(). - Updates
CurrentCharacterIndexresource. - Replaces
CharacterEntrycomponent with new character’s data (*current_entry = ...). - Loads new texture, creates new atlas layout, replaces
Sprite.
The animation and movement systems automatically adapt - they read from CharacterEntry, which just changed.
9. Plugin Registration (mod.rs)
RonAssetPlugin registers a loader so Bevy can deserialize .characters.ron files into CharactersList assets. init_resource creates the CurrentCharacterIndex with its Default value (index 0). The six systems in the tuple run in parallel by default since Bevy detects no conflicting data access between them.
#![allow(unused)]
fn main() {
pub struct CharactersPlugin;
impl Plugin for CharactersPlugin {
fn build(&self, app: &mut App) {
app.add_plugins(RonAssetPlugin::<CharactersList>::new(&["characters.ron"]))
.init_resource::<spawn::CurrentCharacterIndex>()
.add_systems(Startup, spawn::spawn_player)
.add_systems(Update, (
spawn::initialize_player_character,
spawn::switch_character,
movement::move_player,
movement::update_jump_state,
animation::animate_characters,
animation::update_animation_flags,
));
}
}
}
In main.rs:
#![allow(unused)]
fn main() {
mod characters;
// ...
.add_plugins(characters::CharactersPlugin)
// Remove: .add_plugins(PlayerPlugin)
}
10. Rust Concepts Introduced
| Concept | What It Means | Example in This Chapter |
|---|---|---|
HashMap<K, V> | Key-value lookup table | animations: HashMap<AnimationType, AnimationDefinition> |
Serialize / Deserialize | Convert structs to/from text formats (RON, JSON) | #[derive(Serialize, Deserialize)] on all config types |
Asset + TypePath derives | Register a struct as a loadable Bevy asset | CharacterEntry and CharactersList |
| Method chaining | Sequential iterator operations | .values().map(...).max().unwrap_or(0) |
unwrap_or(default) | Extract Option value or use fallback | max().unwrap_or(0) - returns 0 if no animations |
_ in pattern matching | Ignore unused tuple/struct fields | (key, _) to ignore direction, (_, dir) to ignore key |
Dereference * | Access/modify data behind a mutable reference | *current_entry = character_entry.clone() |
Deref / DerefMut | Auto-dereference a wrapper type | AnimationTimer(Timer) auto-delegates to inner Timer |
Option<Res<T>> | Resource that might not exist yet | characters_list_res: Option<Res<CharactersListResource>> |
| Self-terminating queries | Query with Without<C> that stops matching after inserting C | Query<Entity, (With<Player>, Without<AnimationController>)> |
| Component vs Resource | Component = per-entity data; Resource = global singleton | AnimationState (component) vs CurrentCharacterIndex (resource) |
Default derive | Auto-generate default values (0, false, None, "") | CurrentCharacterIndex { index: 0 } via #[derive(Default)] |
.as_ref() vs .as_mut() | Read-only vs mutable borrow of inner Option value | sprite.texture_atlas.as_ref() (read) vs .as_mut() (write) |
11. Tips for Further Improvement
Immediate Enhancements
- Add WASD support - extend
MOVEMENT_KEYSarray with(KeyCode::KeyA, Vec2::NEG_X), etc. - Idle animation - add an
AnimationType::Idlevariant with a slow breathing/swaying cycle. Switch to it when!is_moving && !is_jumpinginstead of freezing on frame 0. - Attack animations - add
AnimationType::Attackto the enum and config. Use the same one-shot pattern as Jump (play once, return to Walk). - Animation blending - instead of hard-cutting between animations, lerp the transition over 2–3 frames for smoother feel.
- Speed-scaled animation - dynamically adjust
frame_timebased on actual movement speed. Faster movement = faster walk cycle.
Architecture Improvements
- State machine (covered in Chapter 4) - replace boolean flags with a proper FSM for cleaner state transitions (Idle → Walk → Run → Jump → Attack → …).
- Character selection UI - replace number key switching with a visual character picker (portrait grid, scroll wheel).
- Hot-reload config - watch
characters.ronfor file changes and reload at runtime. Bevy’s asset server supports this withAssetServer::watch_for_changes(). - Event-driven transitions - emit
AnimationChangedEventwhen switching animations, letting other systems react (play sound effects, spawn particles). - Component separation - split
CharacterEntryinto smaller components (CharacterStats,SpriteConfig,AnimationMap) for more granular ECS queries.
Performance Notes
- Atlas layout caching -
create_character_atlas_layoutcreates a new layout handle on every character switch. Cache layouts per character to avoid redundant allocations. - Query optimization - the
animate_characterssystem queries all entities with animation components. For large entity counts, consider using change detection (Changed<AnimationController>) to skip unchanged entities. - RON parsing - happens once at load time and is negligible. For hundreds of characters, consider binary serialization (
bincode) for faster parsing.
12. Recommended Crates
Data & Serialization
| Crate | Purpose | Notes |
|---|---|---|
serde | Serialize/deserialize Rust structs | Foundation for RON, JSON, TOML, bincode, etc. |
bevy_common_assets | Asset loaders for common formats | RON, JSON, TOML, YAML, CSV, MsgPack support |
ron | RON parser/writer | Direct use for custom tooling; bevy_common_assets wraps it |
bevy_asset_loader | Declarative asset loading states | Loading screens, progress tracking, dependency management |
Animation & State Machines
| Crate | Purpose | Notes |
|---|---|---|
bevy_spritesheet_animation | Declarative sprite animation | Define clips + transitions without manual frame math |
seldom_state | State machine for Bevy entities | Clean replacement for boolean flag state management |
bevy_tweening | Tween/easing animations | Smooth transitions between animation states |
Character Systems
| Crate | Purpose | Notes |
|---|---|---|
leafwing-input-manager | Input abstraction & remapping | Map actions to keys/gamepad instead of hardcoded KeyCode checks |
bevy_rapier2d / avian2d | Physics & collision | Next chapter covers collision detection |
bevy_egui | Immediate-mode UI | Character selection screens, stat displays, debug panels |
Debug & Development
| Crate | Purpose | Notes |
|---|---|---|
bevy-inspector-egui | Runtime entity/component inspector | Inspect CharacterEntry and AnimationState live |
iyes_perf_ui | Performance overlay | FPS, entity count, system timing |
13. Spritesheet Layout Reference
All character spritesheets in this tutorial follow the LPC (Liberated Pixel Cup) convention:
Row Layout for Directional Animations (directional: true):
start_row + 0 → Up frames
start_row + 1 → Left frames
start_row + 2 → Down frames
start_row + 3 → Right frames
Atlas Index Formula:
index = (start_row + direction_offset) × atlas_columns + frame_number
Example: Walk Right, frame 3, atlas_columns = 9, start_row = 8
direction_offset = 3 (Right)
actual_row = 8 + 3 = 11
index = 11 × 9 + 3 = 102
Non-directional animations (like Jump) use a single row regardless of Facing.
API Quick Reference
APIs introduced in this chapter. See the API Glossary for all APIs across the book.
| API | Category | Description |
|---|---|---|
Asset | Assets | Marks a type as a loadable Bevy asset |
TypePath | Assets | Runtime type path info (required for Asset) |
HashMap<K, V> | Rust std | Key-value container with O(1) lookup |
Hash | Rust std | Enables use as HashMap key |
Serialize | serde | Serialization to RON, JSON, etc. |
Deserialize | serde | Deserialization from RON, JSON, etc. |
14. Complete Component Hierarchy
When fully initialized, the player entity has:
| Component | Source | Purpose |
|---|---|---|
Player | spawn_player | Marker for queries (With<Player>) |
Transform | spawn_player | Position, scale, z-order |
Sprite | initialize_player_character | Texture atlas reference + current frame index |
CharacterEntry | initialize_player_character | All character data from .ron |
AnimationController | initialize_player_character | Current animation type + facing direction |
AnimationState | initialize_player_character | Movement/jump flags + previous-frame flags |
AnimationTimer | initialize_player_character | Frame timing (repeating timer) |
Bevy & Rust Game Development - Chapter 4 Reference
1. Chapter Overview
This chapter replaces Chapter 3’s polling-based initialization and boolean animation flags with proper state machines at two levels: game-wide lifecycle states and per-character behavior states. It then builds a complete tile-based collision system with circle colliders, swept collision, debug visualization, and Y-based depth sorting for walk-behind occlusion.
What Gets Built
- Game states -
Loading→Playing↔PausedwithOnEnter/OnExitschedules, loading screen, pause overlay - Character state machine -
CharacterStateenum (Idle/Walking/Running/Jumping) replacing boolean flags - Architecture refactor -
movement.rssplit intoinput.rs(what to do) +physics.rs(how to move) - Facing component - extracted from
AnimationControllerinto its own ECS component - Collision map - flat grid built from WFC tiles, circle-vs-rectangle tests, swept collision with wall-sliding
- Shore detection - water tiles adjacent to walkable land become walkable shore
- Debug overlay - F3 toggles: green/red walkability grid, cyan collider circle, yellow grid cell highlight
- Y-based depth sorting - player Z updated dynamically so they render behind trees/objects higher on screen
- Centralized config -
src/config.rsfor all magic numbers (tile size, grid dimensions, collider radius, player scale)
2. Project Structure After This Chapter
src/
├── main.rs
├── config.rs # NEW - centralized constants
├── state/ # NEW - game lifecycle
│ ├── mod.rs # StatePlugin, check_assets_loaded, toggle_pause
│ ├── game_state.rs # GameState enum (Loading/Playing/Paused)
│ ├── loading.rs # Loading screen UI + animation
│ └── pause.rs # Pause overlay UI
├── collision/ # NEW - tile collision
│ ├── mod.rs # CollisionPlugin
│ ├── tile_type.rs # TileType enum + walkability
│ ├── map.rs # CollisionMap resource (grid, circle tests, sweep)
│ ├── systems.rs # build_collision_map, shore conversion
│ └── debug.rs # Debug visualization (debug builds only)
├── characters/
│ ├── mod.rs # CharactersPlugin (updated system chain)
│ ├── config.rs # AnimationType now has #[default] Walk
│ ├── animation.rs # REFACTORED - on_state_change + tick_animations
│ ├── input.rs # NEW - replaces movement.rs (Player marker, input, state machine)
│ ├── physics.rs # NEW - Velocity component, apply_velocity
│ ├── facing.rs # NEW - Facing component (extracted from AnimationController)
│ ├── state.rs # NEW - CharacterState enum
│ ├── collider.rs # NEW - Collider component, validate_movement
│ ├── rendering.rs # NEW - Y-based depth sorting
│ ├── spawn.rs # Updated (new components, config imports)
│ └── config.rs # Updated (AnimationType default)
└── map/
├── assets.rs # Updated (TileType per SpawnableAsset)
├── rules.rs # Updated (.with_tile_type() on every asset)
└── (rest unchanged)
Deleted files: src/characters/movement.rs (replaced by input.rs + physics.rs)
3. Game States (state/)
The Problem with Polling
Chapter 3’s initialize_player_character ran every frame in Update, checking if assets were loaded. After one useful execution, it became an infinite no-op. This wastes cycles and clutters the system schedule.
State-Based Schedules
Bevy’s States trait provides special one-shot schedules:
| Schedule | When It Runs |
|---|---|
OnEnter(State) | Exactly once when entering a state |
OnExit(State) | Exactly once when leaving a state |
Update.run_if(in_state(State)) | Every frame while in that state |
The game has three lifecycle phases: Loading (wait for assets and show a loading screen), Playing (all gameplay systems active), and Paused (gameplay frozen with an overlay). The #[default] attribute on Loading means the game always starts in the loading state.
#![allow(unused)]
fn main() {
#[derive(States, Default, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GameState {
#[default]
Loading, // Show loading screen, check assets
Playing, // Gameplay active
Paused, // Overlay visible, gameplay frozen
}
}
State Transitions
Game Start
│
▼
Loading ──── OnEnter: spawn_loading_screen
│ Update: check_assets_loaded + animate_loading
│ OnExit: despawn_loading_screen + initialize_player_character
▼
Playing ◄──► Paused
(Esc) (Esc)
│ │
│ OnEnter: spawn_pause_menu
│ OnExit: despawn_pause_menu
│
└── Update systems run_if(in_state(Playing)):
All character, collision, and animation systems
Key Implementation Details
check_assets_loaded - replaces the self-terminating query pattern. Checks if CharactersList asset is loaded; if so, calls next_state.set(GameState::Playing).
toggle_pause - runs in both Playing and Paused states via .run_if(in_state(Playing).or(in_state(Paused))). Listens for Escape key.
Loading screen animation - cycles “Loading” → “Loading.” → “Loading..” → “Loading…” using (time.elapsed_secs() * 2.0) as usize % 4.
Plugin ordering - StatePlugin must be added BEFORE CharactersPlugin in main.rs so the state system is initialized before character systems reference it.
4. Character State Machine (characters/state.rs)
Why Not Booleans
| Boolean Flags (Ch3) | State Enum (Ch4) |
|---|---|
is_moving, was_moving, is_jumping, was_jumping | CharacterState::Walking, ::Jumping, etc. |
Can have impossible combos (is_moving && is_jumping) | Compiler enforces exactly one state |
Manual change detection (was_moving = is_moving) | Bevy’s Changed<CharacterState> filter |
| N states = 2N booleans + transition math | N states = N enum variants + match |
| Adding a state = 2 new bools + update all if-chains | Adding a state = 1 variant + compiler shows every match to update |
The Enum
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CharacterState {
#[default]
Idle,
Walking,
Running,
Jumping,
}
impl CharacterState {
pub fn is_grounded(&self) -> bool {
matches!(self, Idle | Walking | Running)
}
}
}
State Transition Logic (input.rs)
#![allow(unused)]
fn main() {
fn determine_new_state(
current: CharacterState,
direction: Vec2,
is_running: bool,
wants_jump: bool,
) -> CharacterState {
match current {
Jumping => Jumping, // Can't exit until animation completes
_ if wants_jump && current.is_grounded() => Jumping, // Jump priority
_ if direction != Vec2::ZERO => {
if is_running { Running } else { Walking }
}
_ => Idle,
}
}
}
Match guards (_ if condition =>) combine pattern matching with boolean logic. Read top-to-bottom as priority: Jumping locks until complete, then jump overrides movement, then movement, then idle.
5. Architecture Refactor
What Changed
| Old (Ch3) | New (Ch4) | Why |
|---|---|---|
movement.rs (input + physics + animation) | input.rs + physics.rs | Single responsibility |
Facing inside AnimationController | Facing as own component | Movement owns direction, animation owns clips |
AnimationState (4 booleans) | CharacterState enum | Impossible states unrepresentable |
animate_characters (one big system) | on_state_change_update_animation + animations_playback | Change detection + per-frame playback separated |
update_animation_flags | Deleted | Changed<CharacterState> replaces manual tracking |
System Chain (Final Order)
The .chain() ordering is critical because each step depends on the previous one’s output: input determines state and velocity, state drives animation selection, collision adjusts velocity before physics applies it, physics updates the transform, depth sorting reads the new position, and finally animation playback uses the current state and timer.
#![allow(unused)]
fn main() {
.add_systems(Update, (
input::handle_player_input, // 1. Read keys → set state + velocity + facing
spawn::switch_character, // 2. Number keys → swap character data
input::update_jump_state, // 3. Jump animation done? → Idle
animation::on_state_change_update_animation, // 4. State changed? → pick animation type
collider::validate_movement, // 5. Sweep collision → adjust velocity
physics::apply_velocity, // 6. velocity × dt → Transform
rendering::update_player_depth, // 7. Y position → Z depth
animation::animations_playback, // 8. Tick timer → advance frame
).chain().run_if(in_state(GameState::Playing)));
}
.chain() enforces sequential execution. .run_if(in_state(Playing)) freezes everything when paused.
Velocity Component (physics.rs)
Separating velocity from input lets the collision system modify velocity before physics applies it - input says “I want to move here”, collision says “you can actually move there”, and physics executes the final movement. Each CharacterState maps to a clear velocity rule: Idle and Jumping produce zero velocity, Walking uses base speed, and Running multiplies by the character’s run speed factor.
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone, Copy, Default, Deref, DerefMut)]
pub struct Velocity(pub Vec2);
pub fn calculate_velocity(state: CharacterState, direction: Vec2, character: &CharacterEntry) -> Velocity {
match state {
Idle | Jumping => Velocity::ZERO,
Walking => Velocity(direction.normalize_or_zero() * character.base_move_speed),
Running => Velocity(direction.normalize_or_zero() * character.base_move_speed * character.run_speed_multiplier),
}
}
pub fn apply_velocity(time: Res<Time>, mut query: Query<(&Velocity, &mut Transform)>) {
for (velocity, mut transform) in query.iter_mut() {
if velocity.is_moving() {
transform.translation += velocity.0.extend(0.0) * time.delta_secs();
}
}
}
}
apply_velocity is pure physics - no game logic, works for any entity with Velocity + Transform.
6. Collision System (collision/)
TileType Enum
TileType classifies every tile for collision purposes. is_walkable() returns false only for Water, Tree, and Rock - everything else is passable. collision_adjustment() returns a small negative value for trees and rocks, which allows the player to slightly clip corners of these obstacles for a smoother feel.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum TileType {
#[default] Empty,
Dirt, Grass, YellowGrass, Shore, // Walkable
Water, Tree, Rock, // Blocked
}
impl TileType {
pub fn is_walkable(&self) -> bool {
!matches!(self, Water | Tree | Rock)
}
pub fn collision_adjustment(&self) -> f32 {
match self {
Tree | Rock => -0.2, // Negative = allow corner cutting
_ => 0.0,
}
}
}
}
New types default to walkable (safer than defaulting to blocked - forgetting a type creates passable terrain, not invisible walls).
CollisionMap Resource
A flat Vec<TileType> stored in row-major order with coordinate conversion methods.
Key methods:
| Method | Purpose |
|---|---|
world_to_grid(Vec2) → IVec2 | Screen position → tile coordinates |
grid_to_world(i32, i32) → Vec2 | Tile coordinates → screen center of tile |
is_walkable(x, y) → bool | Can entities pass through this tile? |
circle_intersects_tile(center, radius, gx, gy) → bool | Circle-vs-rectangle overlap test |
is_circle_clear(center, radius) → bool | Check all tiles under a circle for obstacles |
sweep_circle(start, end, radius) → Vec2 | Swept collision with axis-sliding, returns furthest valid position |
Origin explained: The map is centered on screen. A 25×18 grid at 32px tiles is 800×576px, so origin = (-400, -288). Without origin offset, world_to_grid(-32, 0) would return tile (-1, 0) instead of the correct (11, 9).
Circle-vs-Rectangle Test
The collision test works by clamping the circle’s center to the tile’s bounding box to find the closest point on the rectangle, then checking if that point is within the circle’s radius. Using distance_squared instead of distance avoids an expensive square root - comparing squared values gives the same result.
#![allow(unused)]
fn main() {
let closest = Vec2::new(
center.x.clamp(tile_min.x, tile_max.x),
center.y.clamp(tile_min.y, tile_max.y),
);
center.distance_squared(closest) <= radius * radius
}
Uses distance_squared to avoid an expensive sqrt.
Swept Collision
Prevents tunneling through thin walls at high speed. Steps along the path in quarter-tile increments:
For each step along start → end:
1. Try full movement → if clear, advance
2. Try X-only slide → if clear, slide horizontally
3. Try Y-only slide → if clear, slide vertically
4. All blocked → stop
The axis-sliding (steps 2–3) is what makes wall-sliding feel smooth. Running diagonally into a vertical wall lets you slide along it instead of stopping dead.
Building the Map (systems.rs)
build_collision_map runs once (gated by resource_equals(CollisionMapBuilt(false))):
- Waits for WFC tiles to exist (early return if no tiles).
- Scans all
TileMarker + Transformentities. - Handles multi-layer: keeps only the topmost tile per (x,y) position (highest Z wins).
- Creates
CollisionMapresource and populates it. - Post-processes: shore conversion (water tiles adjacent to walkable land → Shore).
Shore Conversion
Water tiles touching any walkable neighbor (8-directional check) become Shore. Shore is walkable, so players can approach water edges naturally instead of stopping a full tile away.
Integrating with Map Generation
SpawnableAsset gains .with_tile_type(TileType) builder method. Every asset in rules.rs gets a tile type:
| Asset | TileType | Notes |
|---|---|---|
| Dirt | Dirt | Walkable base layer |
| Green/Yellow grass | Grass/YellowGrass | Walkable |
| Water + all water corners | Water | Blocked (edges become Shore) |
| Tree trunks (bottom sprites) | Tree | Blocked |
| Tree canopies (top sprites) | None | No collision - player walks under canopy |
| Rocks | Rock | Blocked |
| Plants | Grass | Walkable decorations |
| Tree stumps | Tree | Blocked |
7. Player Collider (characters/collider.rs)
A circle collider is used instead of a rectangle because circles slide smoothly along walls without catching on tile corners. When the player runs diagonally into a wall, the circle’s curved edge naturally deflects along the surface instead of getting stuck on grid seams.
#![allow(unused)]
fn main() {
#[derive(Component, Debug, Clone)]
pub struct Collider {
pub radius: f32, // Default: 16.0 (from config::player::COLLIDER_RADIUS)
pub offset: Vec2, // Offset from entity center (default: Vec2::ZERO)
}
}
validate_movement System
Runs after input sets velocity, before physics applies it:
- Get current collider world position.
- Calculate desired position:
current + velocity * dt. - Call
collision_map.sweep_circle(current, desired, radius)→ get valid position. - If sweep modified the path, adjust velocity so physics applies the corrected movement.
This approach means collision doesn’t fight with physics - it cooperates by adjusting velocity before physics reads it.
8. Debug Visualization (collision/debug.rs)
All debug code is wrapped in #[cfg(debug_assertions)] - completely absent from release builds.
| Key | Action |
|---|---|
| F3 | Toggle collision debug overlay |
What the overlay shows:
- Green rectangles (25% opacity) - walkable tiles
- Red rectangles (40% opacity) - blocked tiles
- Cyan circle - player’s collision radius
- Yellow rectangle - grid cell the player is currently in
- Yellow line - from entity center to collider position (shows offset)
- Red X - appears if player is somehow on an unwalkable tile
Uses Bevy’s Gizmos API - shapes auto-disappear each frame, no cleanup needed.
9. Y-Based Depth Sorting (characters/rendering.rs)
The Problem
Player’s Z was fixed at 20.0 (Chapter 1). They always render on top of everything, breaking the illusion when walking behind trees.
The Solution
Dynamically set player Z based on Y position:
- Higher Y (top of screen, “far away”) → lower Z (drawn behind)
- Lower Y (bottom of screen, “close”) → higher Z (drawn in front)
In the formula below, player_feet_y anchors the depth calculation to the character’s feet rather than their center (so the head doesn’t poke above objects they’re standing behind). t normalizes the Y position across the map’s height to a 0–1 range, and (1 - t) inverts it so lower screen positions (closer to the viewer) get higher Z values.
#![allow(unused)]
fn main() {
// Uses feet position, not sprite center, for natural-looking occlusion
let player_feet_y = transform.translation.y - (player_sprite_height / 2.0);
let t = ((player_feet_y - map_y0) / map_height).clamp(0.0, 1.0);
let player_z = PLAYER_BASE_Z + NODE_SIZE_Z * (1.0 - t) + PLAYER_Z_OFFSET;
}
Why feet position? Using sprite center would cause the player’s head to poke above objects they’re visually standing in front of. Feet position matches how we perceive depth in top-down games.
Uses Changed<Transform> filter - only recalculates when the player actually moves.
10. Centralized Configuration (config.rs)
#![allow(unused)]
fn main() {
pub mod player {
pub const COLLIDER_RADIUS: f32 = 16.0;
pub const PLAYER_Z_POSITION: f32 = 20.0;
pub const PLAYER_SCALE: f32 = 0.8;
}
pub mod map {
pub const TILE_SIZE: f32 = 32.0;
pub const GRID_X: u32 = 25;
pub const GRID_Y: u32 = 18;
}
}
Imported throughout the codebase - single place to tune gameplay feel. spawn.rs deletes its local PLAYER_SCALE and PLAYER_Z_POSITION constants in favor of these.
11. Complete Player Entity Components
After full initialization in this chapter:
| Component | Added By | Purpose |
|---|---|---|
Player | spawn_player | Marker for input queries |
Transform | spawn_player | Position, scale, Z-depth |
Sprite | initialize_player_character | Texture atlas + current frame |
CharacterEntry | initialize_player_character | Character data from .ron |
AnimationController | initialize_player_character | Current animation type |
CharacterState | initialize_player_character | Idle/Walking/Running/Jumping |
Velocity | initialize_player_character | Movement vector (px/sec) |
Facing | initialize_player_character | Up/Down/Left/Right |
Collider | initialize_player_character | Circle radius + offset |
AnimationTimer | initialize_player_character | Frame tick timing |
12. Rust Concepts Introduced
| Concept | What It Means | Example |
|---|---|---|
States derive + init_state | Register an enum as a Bevy state machine with OnEnter/OnExit | #[derive(States)] on GameState |
OnEnter(S) / OnExit(S) | One-shot schedules on state transitions | OnExit(Loading) runs initialize_player_character once |
.run_if(in_state(S)) | Gate systems to specific states | Gameplay systems only run in Playing |
Changed<C> query filter | Only matches entities whose component C was modified this frame | Changed<CharacterState> triggers animation update |
Match guards (_ if cond =>) | Combine pattern matching with boolean conditions | _ if wants_jump && current.is_grounded() => Jumping |
matches! macro | Check if value matches a pattern, returns bool | matches!(self, Idle | Walking | Running) |
#[cfg(debug_assertions)] | Compile-time conditional - code only exists in debug builds | Debug collision overlay |
#[inline] | Hint compiler to inline small hot functions | xy_to_idx, in_bounds |
Double dereference **text | Unwrap Bevy’s change-tracking wrapper + dereference the inner reference | **text = format!("Loading{}", ...) |
Tuple struct (.0 access) | Newtype wrapper with unnamed field | DebugCollisionEnabled(pub bool) → debug_enabled.0 |
HashMap::Entry API | Efficient insert-or-update without double lookup | match layer_tracker.entry((x,y)) { Occupied/Vacant } |
| Builder pattern | Chain .with_*() methods to configure a struct | SpawnableAsset::new("grass").with_tile_type(TileType::Grass) |
Deref/DerefMut on newtype | Auto-delegate to inner type’s methods | Velocity(Vec2) acts like Vec2 |
normalize_or_zero() | Normalize vector, return zero if length is ~0 (avoids NaN) | Direction normalization in calculate_velocity |
resource_equals(R) | Run-condition that checks a resource’s value | run_if(resource_equals(CollisionMapBuilt(false))) |
13. Controls Summary
| Key | Action | State Required |
|---|---|---|
| Arrow keys | Move 4/8 directions | Playing |
| Shift (L/R) | Sprint | Playing |
| Space | Jump (plays once) | Playing, grounded |
| 1–6 | Switch character | Playing |
| Escape | Toggle pause | Playing or Paused |
| F3 | Toggle collision debug | Playing (debug builds only) |
14. Known Issues & Tips
Spawn Issues
- Player may spawn on a blocking tile (tree/rock) due to random WFC generation. Restart to regenerate.
- Reduce
WATER_WEIGHTto0.001inbuild_water_layerto minimize water-spawn risk.
Tuning Collision Feel
| Constant | Effect |
|---|---|
COLLIDER_RADIUS (config.rs) | Larger = more clearance from obstacles, smaller = tighter squeezing |
collision_adjustment (tile_type.rs) | Negative = allow corner cutting. -0.2 on trees/rocks lets players clip corners |
sweep step = tile_size * 0.25 | Smaller = smoother but more expensive. 0.25 is a good balance |
Performance Notes
build_collision_mapusesHashMap<(i32,i32), (TileType, f32)>for multi-layer dedup - runs once.validate_movementcallssweep_circleper moving entity per frame. For many entities, consider spatial hashing.- Debug gizmos are free in release builds (
#[cfg(debug_assertions)]). Changed<CharacterState>andChanged<Transform>filters skip unchanged entities automatically.
15. Recommended Crates
Collision & Physics
| Crate | Purpose | Notes |
|---|---|---|
avian2d | Full 2D physics engine for Bevy | Replaces manual collision with rigid bodies, sensors, joints |
bevy_rapier2d | Rapier physics integration | Alternative physics engine, more mature ecosystem |
bevy_ecs_tilemap | High-performance tilemap renderer | Built-in tile queries, better than manual grid for large maps |
State Machines
| Crate | Purpose | Notes |
|---|---|---|
seldom_state | Entity state machines for Bevy | Transition conditions, state enter/exit hooks per-entity |
bevy_state | Built-in (used in this chapter) | States, OnEnter, OnExit, run_if(in_state(...)) |
Debug & Visualization
| Crate | Purpose | Notes |
|---|---|---|
bevy-inspector-egui | Runtime entity/component inspector | Live-edit Collider, CharacterState, Velocity |
bevy_mod_debugdump | Visualize system execution order | Verify .chain() ordering is correct |
API Quick Reference
APIs introduced in this chapter. See the API Glossary for all APIs across the book.
| API | Category | Description |
|---|---|---|
States | Core ECS | Derive for state machine enums |
OnEnter | Core ECS | One-shot schedule on state entry |
OnExit | Core ECS | One-shot schedule on state exit |
in_state() | Core ECS | Run-condition gating systems to a state |
.run_if() | Core ECS | Conditional system execution |
.chain() | Core ECS | Enforces sequential system execution |
Changed<C> | Core ECS | Filter for modified components |
Without<T> | Core ECS | Query filter excluding entities with a component |
Gizmos | Rendering | Immediate-mode debug shape drawing |
IVec2 | Math | Signed integer 2D vector |
Bevy & Rust Game Development - Chapter 5 Reference
1. Chapter Overview
This chapter adds two independent features: a pickup/inventory system (walk near items to collect them) and a smooth follow camera with a zoomed-in view. It also introduces Rust’s borrow checker and ownership model as core concepts.
What Gets Built
Pickablecomponent +Inventoryresource - distance-based collection with HashMap countingItemKindenum - Plant1–4, TreeStump with display namesSpawnableAssetgains.with_pickable(ItemKind)builder method- Smooth lerp-based camera follow with pixel snapping to prevent grid shimmer
- 2× world scale (32px tiles → 64px rendered) for zoomed-in exploration feel
- Borderless fullscreen window mode
- Centralized config expanded:
pickup,camera, updatedplayerandmapmodules
2. Project Structure After This Chapter
src/
├── main.rs # Updated: fullscreen, CameraPlugin, InventoryPlugin
├── config.rs # Updated: +pickup, +camera modules, scaled values
├── camera/ # NEW
│ ├── mod.rs # CameraPlugin
│ └── camera.rs # MainCamera marker, setup_camera, follow_camera
├── inventory/ # NEW
│ ├── mod.rs # InventoryPlugin
│ ├── inventory.rs # ItemKind, Pickable, Inventory
│ └── systems.rs # handle_pickups
├── state/ # Unchanged
├── collision/ # Unchanged
├── characters/ # Unchanged
└── map/
├── assets.rs # Updated: +pickable field, create_spawner handles pickables
├── rules.rs # Updated: .with_pickable() on plants/stumps
└── generate.rs # Updated: uses config constants, 2× ASSETS_SCALE, no map_pixel_dimensions
Deleted: setup_camera function from main.rs (moved to camera/camera.rs), local constants in generate.rs (GRID_X, GRID_Y, TILE_SIZE, map_pixel_dimensions).
3. Inventory System (inventory/)
ItemKind Enum
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ItemKind {
Plant1, // "Herb"
Plant2, // "Flower"
Plant3, // "Mushroom"
Plant4, // "Fern"
TreeStump, // "Wood"
}
}
display_name() returns human-readable strings. fmt::Display is implemented so format!("{}", item) works directly.
Pickable Component
#![allow(unused)]
fn main() {
#[derive(Component, Debug)]
pub struct Pickable {
pub kind: ItemKind,
pub radius: f32, // Default: 40.0 from config::pickup::DEFAULT_RADIUS
}
}
Attached to world entities during map generation. Per-item radius allows future items with different pickup ranges (e.g., magnetic orbs with larger radius, treasure chests requiring close proximity).
Inventory Resource
#![allow(unused)]
fn main() {
#[derive(Resource, Default, Debug)]
pub struct Inventory {
items: HashMap<ItemKind, u32>, // Item kind → count
}
impl Inventory {
pub fn add(&mut self, kind: ItemKind) -> u32 { /* returns new count */ }
pub fn summary(&self) -> String { /* "Herb: 3, Flower: 1" */ }
}
}
Why HashMap over Vec? Counting Plant1 items in a Vec requires scanning the entire list. HashMap stores counts directly - O(1) lookup and increment via .entry(kind).or_insert(0).
Pickup Detection (systems.rs)
#![allow(unused)]
fn main() {
pub fn handle_pickups(
mut commands: Commands,
mut inventory: ResMut<Inventory>,
player_query: Query<&Transform, With<Player>>,
pickables: Query<(Entity, &GlobalTransform, &Pickable)>,
) {
// 1. Get player position
// 2. Collect items within radius (distance_squared for performance)
// 3. Despawn collected entities + add to inventory
}
}
Key details:
- Uses
distance_squaredinstead ofdistanceto avoid expensivesqrt- comparedist² ≤ radius²instead. - Uses
GlobalTransformfor pickables (notTransform) because items may be children of other entities. - Collect-then-process pattern: gathers items into a
Vecbefore despawning, following Rust’s borrow checker best practice of separating reads from writes.
Integration with Map Generation
SpawnableAsset gains a pickable: Option<ItemKind> field and .with_pickable(kind) builder method.
create_spawner updated to handle (tile_type, pickable) tuple matching:
| Asset | TileType | Pickable | Result |
|---|---|---|---|
plant_1 | Grass | Plant1 | Walkable + collectible |
plant_2 | Grass | Plant2 | Walkable + collectible |
plant_3 | Grass | Plant3 | Walkable + collectible |
plant_4 | Grass | Plant4 | Walkable + collectible |
tree_stump_2 | Tree | TreeStump | Blocking + collectible |
tree_stump_1, tree_stump_3 | Tree | None | Blocking, not collectible |
| All other tiles | Various | None | Normal collision behavior |
Only tree_stump_2 is pickable - others remain pure obstacles. This adds world variety.
4. Camera System (camera/)
Config Values
#![allow(unused)]
fn main() {
pub mod camera {
pub const CAMERA_LERP_SPEED: f32 = 6.0; // Higher = snappier follow
pub const CAMERA_Z: f32 = 1000.0; // Above all game layers
}
}
Follow Camera
#![allow(unused)]
fn main() {
pub fn follow_camera(
time: Res<Time>,
player_query: Query<&Transform, (With<Player>, Changed<Transform>)>,
mut camera_query: Query<&mut Transform, (With<MainCamera>, Without<Player>)>,
) {
// 1. Early exit if player hasn't moved (Changed<Transform> filter)
// 2. Early exit if camera within 0.5px of player
// 3. Lerp toward player: lerp_factor = (SPEED * dt).clamp(0.0, 1.0)
// 4. Pixel-snap with .round() to prevent grid shimmer
}
}
Lerp explained: Each frame, camera moves lerp_factor fraction of the remaining distance to the player. At 60fps with speed 6.0: 6.0 × 0.0167 ≈ 0.1, so camera closes 10% of the gap per frame. This creates smooth exponential decay - fast when far, slow when close.
Why .clamp(0.0, 1.0)? At very low framerates (e.g., 2fps), dt could be 0.5s → factor = 3.0, which would overshoot. Clamping prevents this.
Why .round() pixel snapping? Without it, camera lands on fractional coordinates (10.3, 25.7), causing tile edges to render at inconsistent subpixel positions → visible “shimmer” or flickering grid lines.
Changed<Transform> filter: Bevy skips the entire system when the player hasn’t moved. Zero cost when standing still.
5. World Scale Changes
Before → After
| Config | Chapter 4 | Chapter 5 | Why |
|---|---|---|---|
TILE_SIZE | 32.0 | 64.0 | Zoomed-in view |
PLAYER_SCALE | 0.8 | 1.2 | Larger player to match scaled world |
COLLIDER_RADIUS | 16.0 | 24.0 | Proportional to new scale |
ASSETS_SCALE | Vec3::ONE | Vec3::new(2.0, 2.0, 1.0) | 32px sprites rendered at 64px |
NODE_SIZE_Z | 1.0 (local) | 1.0 (from config) | Centralized |
| Window | Fixed size (800×576) | Borderless fullscreen | Immersive camera view |
| Background | Color::WHITE | Color::BLACK | Cleaner look |
What Got Deleted from generate.rs
- Local
GRID_X,GRID_Y,TILE_SIZEconstants → now imported fromconfig::map map_pixel_dimensions()function → no longer needed (fullscreen, no window sizing)
6. Updated main.rs
Plugins are registered in dependency order: StatePlugin first (provides GameState that other systems reference), then CameraPlugin and InventoryPlugin (independent features), then CollisionPlugin (builds the collision map), and finally CharactersPlugin (depends on collision and state). ProcGenSimplePlugin handles WFC generation and tile spawning.
fn main() {
App::new()
.insert_resource(ClearColor(Color::BLACK))
.add_plugins(DefaultPlugins
.set(AssetPlugin { file_path: "src/assets".into(), ..default() })
.set(WindowPlugin {
primary_window: Some(Window {
title: "Bevy Game".into(),
mode: WindowMode::BorderlessFullscreen(MonitorSelection::Current),
..default()
}),
..default()
})
.set(ImagePlugin::default_nearest()),
)
.add_plugins(ProcGenSimplePlugin::<Cartesian3D, Sprite>::default())
.add_plugins(state::StatePlugin)
.add_plugins(CameraPlugin) // NEW
.add_plugins(inventory::InventoryPlugin) // NEW
.add_plugins(collision::CollisionPlugin)
.add_plugins(characters::CharactersPlugin)
.add_systems(Startup, setup_generator) // setup_camera removed (in CameraPlugin)
.run();
}
Plugin order: State → Camera → Inventory → Collision → Characters.
7. Centralized Config (config.rs) - Full State
Values changed from Chapter 4 because the world is now rendered at 2× zoom: TILE_SIZE doubled from 32 to 64 (32px sprites scaled up), COLLIDER_RADIUS increased from 16 to 24 to stay proportional, and PLAYER_SCALE grew from 0.8 to 1.2 so the character matches the larger tile rendering.
#![allow(unused)]
fn main() {
pub mod player {
pub const COLLIDER_RADIUS: f32 = 24.0;
pub const PLAYER_Z_POSITION: f32 = 20.0;
pub const PLAYER_SCALE: f32 = 1.2;
}
pub mod map {
pub const TILE_SIZE: f32 = 64.0;
pub const GRID_X: u32 = 25;
pub const GRID_Y: u32 = 18;
pub const NODE_SIZE_Z: f32 = 1.0;
}
pub mod pickup {
pub const DEFAULT_RADIUS: f32 = 40.0;
}
pub mod camera {
pub const CAMERA_LERP_SPEED: f32 = 6.0;
pub const CAMERA_Z: f32 = 1000.0;
}
}
8. Rust Concepts Introduced
The Borrow Checker & Ownership
This chapter provides the deepest Rust-specific teaching in the series so far.
Core rules:
- Many readers OR one writer, never both. You can have multiple
&T(immutable borrows) or one&mut T(mutable borrow), but not both simultaneously. - References must always be valid. A reference cannot outlive the data it points to (no dangling pointers).
- Borrows end at last use, not at scope end (Non-Lexical Lifetimes / NLL).
Why it matters for games:
- No garbage collector pauses (unlike Python/JS) - the compiler inserts cleanup code at compile time.
- No dangling pointers (unlike C/C++) - the compiler rejects code that would access freed memory.
- The collect-then-process pattern (gather items to remove into a Vec, then remove them) exists because you can’t mutate a collection while iterating over it.
In Bevy context: Bevy’s Commands is deferred (changes apply after the system finishes), so the strict borrow rule doesn’t technically apply to entity despawning. But the collect-then-process pattern is still used as a Rust best practice that works everywhere.
Other Concepts
| Concept | What It Means | Example |
|---|---|---|
fmt::Display trait | Implement to enable {} formatting for custom types | impl fmt::Display for ItemKind |
HashMap::entry().or_insert() | Get-or-create pattern for efficient upsert | self.items.entry(kind).or_insert(0) |
.truncate() | Convert Vec3 → Vec2 (drop Z) | transform.translation.truncate() |
distance_squared | Avoid sqrt when only comparing distances | pos.distance_squared(other) <= radius * radius |
lerp (linear interpolation) | Smooth transition between two values | camera_pos.lerp(player_pos, factor) |
.round() pixel snapping | Prevent subpixel rendering artifacts | new_pos.x.round() |
Changed<C> as optimization | Skip system entirely when component unchanged | Query<..., Changed<Transform>> |
Without<T> in camera query | Prevent query from matching player entity | Query<..., (With<MainCamera>, Without<Player>)> |
GlobalTransform vs Transform | World-space position (accounts for parent hierarchy) vs local | GlobalTransform for pickables that may be child entities |
BorderlessFullscreen | Fullscreen without window chrome | WindowMode::BorderlessFullscreen(MonitorSelection::Current) |
Tuple matching in match | Match on multiple values simultaneously | match (tile_type, pickable) { (Some(Grass), Some(Plant1)) => ... } |
9. Controls Summary (Cumulative)
| Key | Action | State |
|---|---|---|
| Arrow keys | Move | Playing |
| Shift | Sprint | Playing |
| Space | Jump | Playing, grounded |
| 1–6 | Switch character | Playing |
| Escape | Toggle pause | Playing/Paused |
| F3 | Toggle collision debug | Playing (debug only) |
| Walk near items | Auto-collect pickups | Playing |
10. Tips for Further Improvement
Inventory Enhancements
- Inventory UI - render collected items as icons with counts (Chapter 6 preview).
- Stack limits - cap item counts per type, show “Inventory Full” feedback.
- Drop items - reverse of pickup: spawn entity from inventory back into world.
- Crafting - combine items (3 Herbs + 1 Mushroom → Potion).
- Item rarity/weight - extend
ItemKindor addItemDefinitiondata file (RON).
Camera Enhancements
- Camera bounds clamping - prevent camera from showing areas beyond the map edge. Clamp camera position to
[map_min + half_viewport, map_max - half_viewport]. - Camera shake - on pickup or collision, add temporary random offset for juice.
- Zoom controls - scroll wheel to adjust
OrthographicProjection.scale. - Look-ahead - offset camera slightly in the player’s movement direction for better visibility.
- Deadzone - only start following when player moves beyond a small radius from camera center.
Performance Notes
- Pickup spatial indexing - currently checks distance to every pickable entity every frame. For hundreds of items, use spatial hashing or only check nearby grid cells.
- Camera
Changed<Transform>- already optimized; zero cost when player is stationary. distance_squared- already optimized; avoids sqrt per-item per-frame.
11. Recommended Crates
Inventory & Items
| Crate | Purpose | Notes |
|---|---|---|
bevy_egui | Immediate-mode UI | Quick inventory panels, item tooltips |
bevy_ui | Built-in Bevy UI | Node-based layouts for inventory grids |
serde | Serialization | Save/load inventory to disk |
Camera
| Crate | Purpose | Notes |
|---|---|---|
bevy_pancam | Pan/zoom camera | Drag-to-pan, scroll-to-zoom, bounds clamping |
bevy_pixel_camera | Pixel-perfect rendering | Eliminates subpixel artifacts without manual .round() |
iyes_perf_ui | Performance overlay | Monitor FPS impact of pickup checks and camera updates |
World Interaction
| Crate | Purpose | Notes |
|---|---|---|
bevy_mod_picking | Click/hover detection | Mouse-based item interaction alternative to proximity |
leafwing-input-manager | Input abstraction | Rebindable “interact” key for pickups instead of auto-collect |
API Quick Reference
APIs introduced in this chapter. See the API Glossary for all APIs across the book.
| API | Category | Description |
|---|---|---|
Entity | Core ECS | Unique identifier for an ECS entity |
GlobalTransform | Math | World-space transform (parent hierarchy) |
OrthographicProjection | Rendering | Camera projection for zoom control |
WindowMode | Plugins | Fullscreen/windowed/borderless mode |
MonitorSelection | Plugins | Which monitor for fullscreen |
fmt::Display | Rust std | User-facing {} string formatting |
Building a Rain System in Bevy - A Learning Guide
This tutorial walks you through adding a rain weather system to your game, step by step. Each section explains what you’re doing, why it works that way, and gives you pointers to learn more.
Take your time with each step. Run cargo check often to catch mistakes early.
Table of Contents
- Step 1: Set Up the Weather Module
- Step 2: Create the Weather State
- Step 3: Make It Rain - Spawning Raindrops
- Step 4: Move and Clean Up Raindrops
- Step 5: Darken the Screen
- Step 6: Slow the Player
- Step 7: Spawn Puddles
- Reference: Z-Ordering Cheat Sheet
- Challenges: Take It Further
Step 1: Set Up the Weather Module
Before writing any rain logic, you need a place for it to live. You’ll create a new module - just like you already have src/map/ and src/characters/.
What to do
- Create the folder
src/weather/ - Create the file
src/weather/mod.rs - Register it in
src/main.rs
The module file
In src/weather/mod.rs, start with just the plugin skeleton:
#![allow(unused)]
fn main() {
use bevy::prelude::*;
pub struct WeatherPlugin;
impl Plugin for WeatherPlugin {
fn build(&self, app: &mut App) {
// We'll add systems here as we build them
}
}
}
Register it in main.rs
Add two things to src/main.rs:
#![allow(unused)]
fn main() {
mod weather; // Tell Rust this module exists (next to your other mod declarations)
}
And in the App::new() chain:
#![allow(unused)]
fn main() {
.add_plugins(weather::WeatherPlugin) // Register the plugin
}
Run cargo check now!
You should get a clean compile. If not, fix any issues before moving on.
💡 Concept: Plugins
A
Pluginin Bevy is just a way to group related setup code. When you call.add_plugins(MyPlugin), Bevy calls yourbuild()method, which is where you register systems, resources, and events.Look at how
CharactersPlugininsrc/characters/mod.rsdoes this - yourWeatherPluginwill follow the same pattern.📖 Learn more: https://bevyengine.org/learn/quick-start/getting-started/plugins/
Step 2: Create the Weather State
You need a way to track whether it’s raining. In Bevy, shared game state lives in Resources.
What to do
- Create
src/weather/state.rs - Define a
WeatherStateresource - Add a system that toggles rain with the
Rkey - Wire it up in the plugin
The resource
Think about what you need to track:
- Is it currently raining? (
bool) - How much should rain slow the player? (
f32)
#![allow(unused)]
fn main() {
#[derive(Resource)]
pub struct WeatherState {
pub is_raining: bool,
pub rain_speed_multiplier: f32,
}
}
You’ll also need a Default implementation so Bevy can create it automatically. The 0.7 multiplier means the player moves at 70% speed during rain - using a multiplier rather than a fixed reduction means it scales naturally with different characters’ base speeds.
#![allow(unused)]
fn main() {
impl Default for WeatherState {
fn default() -> Self {
Self {
is_raining: false,
rain_speed_multiplier: 0.7, // 30% slower when raining
}
}
}
}
The toggle system
Write a system that listens for the R key and flips is_raining:
#![allow(unused)]
fn main() {
pub fn toggle_rain(
input: Res<ButtonInput<KeyCode>>,
mut weather: ResMut<WeatherState>,
) {
if input.just_pressed(KeyCode::KeyR) {
weather.is_raining = !weather.is_raining;
println!("Rain: {}", if weather.is_raining { "ON" } else { "OFF" });
}
}
}
💡 Concept:
Res<T>vsResMut<T>
Res<T>gives you read-only access to a resourceResMut<T>gives you mutable (read-write) accessBevy uses this distinction for its parallel scheduling - systems that only read a resource can run at the same time, but if one writes to it, Bevy knows to run it exclusively.
📖 Learn more: https://bevyengine.org/learn/quick-start/getting-started/resources/
💡 Concept:
just_pressedvspressed
just_pressed()- true only on the first frame the key goes downpressed()- true every frame while the key is heldFor a toggle, you want
just_pressed. For movement (like your arrow keys), you wantpressed. Look at howsrc/characters/movement.rsuses both!
Wire it up
In src/weather/mod.rs:
#![allow(unused)]
fn main() {
pub mod state;
use bevy::prelude::*;
use state::WeatherState;
pub struct WeatherPlugin;
impl Plugin for WeatherPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WeatherState>()
.add_systems(Update, state::toggle_rain);
}
}
}
Run cargo run and test it!
Press R and you should see “Rain: ON” / “Rain: OFF” in your terminal. Nothing visual yet - that’s next.
💡 Tip:
init_resourcevsinsert_resource
init_resource::<T>()- creates the resource using itsDefaultimplinsert_resource(my_value)- inserts a specific value you provideSince we implemented
DefaultforWeatherState,init_resourceis cleaner.
Step 3: Make It Rain - Spawning Raindrops
Now for the fun part. Each raindrop will be its own entity with a tiny sprite.
Add a random number dependency
You’ll need random numbers for raindrop positions. Add fastrand to your Cargo.toml:
[dependencies]
# ... your existing dependencies ...
fastrand = "2"
💡 Other ways to do this
randcrate - the standard Rust RNG library, more features but heavierbevy_rand- Bevy plugin that integratesrandas a resourcefastrand- tiny, fast, no dependencies. Perfect for our use case.📖 Check out the
randbook if you want to learn about RNG in Rust: https://rust-random.github.io/book/
What to do
- Create
src/weather/rain.rs - Define
RaindropandRaindropVelocitycomponents - Write a
spawn_raindropssystem
The components
Each raindrop needs:
- A way to identify it (marker component)
- Its own velocity (so drops move at slightly different speeds)
Raindrop is a marker component - it holds no data, but lets you query for “all raindrop entities.” RaindropVelocity stores a per-entity velocity vector so each drop falls at a slightly different speed and angle, creating natural visual variation.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use crate::weather::state::WeatherState;
/// Marker component - lets us query for "all raindrop entities"
#[derive(Component)]
pub struct Raindrop;
/// Each drop has its own velocity for natural variation
#[derive(Component)]
pub struct RaindropVelocity(pub Vec2);
}
💡 Concept: Marker Components
A component with no data (like
Raindropor your existingPlayer) is called a “marker component”. Its only purpose is to tag entities so you can find them with queries. You already use this pattern - look atTilemapGeneratorinsrc/map/generate.rsandPlayerinsrc/characters/movement.rs.
The spawn system
Think about what this system needs to do each frame:
- Check if it’s raining (if not, do nothing)
- Count existing raindrops (don’t spawn too many)
- Spawn a few new drops at random positions above the screen
Here’s the skeleton - try filling in the spawn logic yourself:
#![allow(unused)]
fn main() {
pub fn spawn_raindrops(
mut commands: Commands,
weather: Res<WeatherState>,
query: Query<&Raindrop>, // Used to count existing drops
) {
if !weather.is_raining {
return;
}
let current_count = query.iter().count();
let max_drops = 200;
let drops_per_frame = 4;
if current_count >= max_drops {
return;
}
// Your screen is 800x576, centered at (0,0)
// So X ranges from -400 to 400, Y from -288 to 288
let half_width = 400.0;
let top_y = 300.0; // slightly above the screen top
for _ in 0..drops_per_frame {
// Random X position across the screen width
let x = fastrand::f32() * half_width * 2.0 - half_width;
// Start just above the visible area
let y = top_y + fastrand::f32() * 50.0;
// Base velocity: slight wind to the right, falling fast
// Add some randomness so drops don't all move identically
let vx = 30.0 + (fastrand::f32() * 20.0 - 10.0);
let vy = -300.0 - fastrand::f32() * 100.0;
commands.spawn((
Raindrop,
RaindropVelocity(Vec2::new(vx, vy)),
// This is how you make a colored rectangle sprite with no texture
Sprite {
color: Color::srgba(0.7, 0.8, 1.0, 0.6),
custom_size: Some(Vec2::new(2.0, 6.0)),
..default()
},
Transform::from_xyz(x, y, 50.0),
));
}
}
}
Register the module and system
In src/weather/mod.rs, add:
#![allow(unused)]
fn main() {
pub mod rain;
}
And in the plugin’s build(), add the system:
#![allow(unused)]
fn main() {
.add_systems(Update, (
state::toggle_rain,
rain::spawn_raindrops,
))
}
Run it and see what happens!
Press R and you should see a bunch of tiny blue rectangles appear at the top of the screen… but they won’t move yet. That’s the next step.
💡 Concept:
commands.spawn()
commands.spawn((A, B, C))creates a new entity with components A, B, and C. The components are passed as a tuple (that’s the double parentheses).Bevy calls these “bundles” - any tuple of components works. Notice how your existing code in
src/characters/spawn.rsdoes the same thing:#![allow(unused)] fn main() { commands.spawn((Player, Transform::from_translation(...), Sprite::default())); }
💡 Understanding
Spritewithout a textureNormally you load an image for a sprite. But you can also set
custom_sizeandcolorto create a simple colored rectangle - no image needed. This is perfect for particles, UI elements, or debug visualization.Try experimenting with different sizes and colors! What does a
(1.0, 10.0)raindrop look like vs(3.0, 3.0)?
Step 4: Move and Clean Up Raindrops
Spawning is only half the story. You need to:
- Move drops downward each frame
- Remove them when they go off screen
Move system
This is one of the simplest systems you’ll write. For each raindrop, apply its velocity. Each raindrop’s position updates by its velocity multiplied by delta time, and the With<Raindrop> filter ensures only raindrop entities are affected - not the player or other sprites.
#![allow(unused)]
fn main() {
pub fn move_raindrops(
time: Res<Time>,
mut query: Query<(&mut Transform, &RaindropVelocity), With<Raindrop>>,
) {
for (mut transform, velocity) in query.iter_mut() {
transform.translation.x += velocity.0.x * time.delta_secs();
transform.translation.y += velocity.0.y * time.delta_secs();
}
}
}
💡 Concept: Delta Time
time.delta_secs()gives you the time since the last frame in seconds. Multiplying velocity by delta time makes movement frame-rate independent - drops fall at the same speed whether you’re running at 30fps or 144fps.Without delta time, faster computers would have faster rain! This is the same pattern used in
src/characters/movement.rsline 63.📖 Learn more: search for “frame rate independence game development”
💡 Concept: Query Filters with
With<T>
Query<(&mut Transform, &RaindropVelocity), With<Raindrop>>means:
- Give me
TransformandRaindropVelocitycomponents…- But only from entities that also have a
RaindropcomponentThe
With<Raindrop>is a filter - it narrows down which entities match. You don’t need theRaindropdata itself, just its presence. Compare this to howmove_playerusesWith<Player>insrc/characters/movement.rs.
Despawn system
Remove drops that fall below the screen, and remove ALL drops when rain stops. The -320.0 threshold is just below the visible screen bottom (-288), giving drops a small buffer so they don’t visibly pop out of existence. The !weather.is_raining check provides instant cleanup when the player toggles rain off.
#![allow(unused)]
fn main() {
pub fn despawn_raindrops(
mut commands: Commands,
weather: Res<WeatherState>,
query: Query<(Entity, &Transform), With<Raindrop>>,
) {
for (entity, transform) in query.iter() {
if transform.translation.y < -320.0 || !weather.is_raining {
commands.entity(entity).despawn();
}
}
}
}
💡 Concept:
EntityWhen you query for
Entity, you get the entity’s ID - a unique handle that Bevy uses internally. You need this ID to despawn it:commands.entity(id).despawn().This is the same pattern used in
regenerate_mapinsrc/map/generate.rs.
Register both systems
In your plugin’s build():
#![allow(unused)]
fn main() {
.add_systems(Update, (
state::toggle_rain,
rain::spawn_raindrops,
rain::move_raindrops,
rain::despawn_raindrops,
))
}
Run it!
Press R and you should now see rain falling. Press R again and it should stop and all drops disappear.
Play with the values! Try changing:
drops_per_frame- more = denser rainmax_drops- higher cap = more on screen- The velocity values - make it a storm or a drizzle
- The wind (
vx) - try negative values for wind blowing left - The sprite color and size
Step 5: Darken the Screen
A rain overlay gives atmosphere. This is the simplest part - one big semi-transparent sprite.
What to do
- Create
src/weather/overlay.rs - Spawn/despawn a dark overlay based on rain state
The system
The overlay spawns when rain starts and despawns when it stops. It sits at Z=45 - above the player (Z=20) but below the raindrops (Z=50) - so the dimming effect covers the world and player while rain falls visibly on top. The dark blue-black color at 30% opacity creates an atmospheric “overcast” feel.
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use crate::weather::state::WeatherState;
#[derive(Component)]
pub struct RainOverlay;
pub fn manage_rain_overlay(
mut commands: Commands,
weather: Res<WeatherState>,
query: Query<Entity, With<RainOverlay>>,
) {
let overlay_exists = !query.is_empty();
if weather.is_raining && !overlay_exists {
// Spawn a big dark rectangle covering the whole screen
commands.spawn((
RainOverlay,
Sprite {
color: Color::srgba(0.0, 0.0, 0.1, 0.3), // dark blue-black, 30% opacity
custom_size: Some(Vec2::new(800.0, 576.0)),
..default()
},
Transform::from_xyz(0.0, 0.0, 45.0), // above map + player, below rain
));
} else if !weather.is_raining && overlay_exists {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}
}
}
Register pub mod overlay; in mod.rs and add overlay::manage_rain_overlay to your systems tuple.
💡 Tip: Experiment with the overlay
- Try
Color::srgba(0.0, 0.0, 0.2, 0.4)for a heavier storm- Try
Color::srgba(0.1, 0.1, 0.0, 0.2)for a yellowish haze- What if you placed the overlay at Z=15 (between the map and the player)? The player would appear “above” the darkness. Try it!
💡 Concept: Z-ordering in 2D
In Bevy 2D, the Z value of a
Transformcontrols draw order. Higher Z renders on top. Your game’s current layers:
- Tilemap: Z ~0-5
- Player: Z=20
By placing the overlay at Z=45, it draws on top of everything except the raindrops (Z=50). See the cheat sheet at the end of this guide.
Step 6: Slow the Player
This is a small change to an existing file - src/characters/movement.rs.
What to do
The move_player function currently calculates speed like this (line 62):
#![allow(unused)]
fn main() {
let move_speed = calculate_movement_speed(character, is_running);
}
You need to:
- Import
WeatherState - Add it as a system parameter
- Apply the multiplier
The changes
At the top of src/characters/movement.rs, add:
#![allow(unused)]
fn main() {
use crate::weather::state::WeatherState;
}
Add the weather parameter to move_player:
#![allow(unused)]
fn main() {
pub fn move_player(
input: Res<ButtonInput<KeyCode>>,
time: Res<Time>,
weather: Res<WeatherState>, // ADD THIS
mut query: Query</* ... same as before ... */>,
) {
}
Then modify the speed calculation:
#![allow(unused)]
fn main() {
let mut move_speed = calculate_movement_speed(character, is_running);
if weather.is_raining {
move_speed *= weather.rain_speed_multiplier;
}
}
That’s it - three small changes to one file.
💡 Concept: Systems are just functions
Notice how Bevy systems are regular Rust functions. The parameters tell Bevy what data the system needs. When you add
weather: Res<WeatherState>, Bevy automatically provides it. You don’t call these functions yourself - Bevy calls them for you with the right arguments.This is called dependency injection. If you’ve used frameworks in other languages, it’s the same idea. Bevy’s version is compile-time checked though, so you get errors at build time if something is wrong.
💡 Think about: Why a multiplier?
We store
rain_speed_multiplier: 0.7in the resource instead of hardcoding it. This means you could later:
- Make different weather intensities (light rain = 0.9, storm = 0.5)
- Let the player equip boots that modify the multiplier
- Gradually change it as rain intensifies
Keeping values as data instead of code is a key pattern in game development.
Step 7: Spawn Puddles
Puddles appear gradually while it’s raining and disappear when rain stops.
What to do
- Create
src/weather/puddles.rs - Use a timer to control spawn rate (not every frame!)
- Place puddles at random tile positions on the map
Understanding the map coordinates
Your tilemap is spawned with its bottom-left corner at (-400, -288). Each tile is 32x32 pixels. So the center of tile at grid position (col, row) is at:
world_x = -400.0 + (col as f32 + 0.5) * 32.0
world_y = -288.0 + (row as f32 + 0.5) * 32.0
Where col ranges from 0 to 24 and row from 0 to 17 (your 25x18 grid).
💡 Tip: Draw it out
If coordinate math is confusing, grab paper and sketch it:
- Origin (0,0) is the screen center
- The map’s bottom-left is at (-400, -288)
- Each tile is 32px wide and tall
- Tile (0,0) center is at (-400 + 16, -288 + 16) = (-384, -272)
- Tile (24,17) center is at (-400 + 24.532, -288 + 17.532) = (384, 272)
The timer resource
You don’t want to spawn puddles every frame - that would be 60 puddles per second! Use a Timer:
Puddle is a marker component for querying puddle entities. PuddleSpawnTimer wraps a Timer in a Resource so it’s shared globally rather than per-entity. Note the plugin uses insert_resource (with a specific timer value) rather than init_resource (which would need a Default impl).
#![allow(unused)]
fn main() {
use bevy::prelude::*;
use crate::weather::state::WeatherState;
#[derive(Component)]
pub struct Puddle;
#[derive(Resource)]
pub struct PuddleSpawnTimer(pub Timer);
}
Register it in your plugin:
#![allow(unused)]
fn main() {
.insert_resource(puddles::PuddleSpawnTimer(
Timer::from_seconds(0.8, TimerMode::Repeating)
))
}
💡 Concept: Timers
Timer::from_seconds(0.8, TimerMode::Repeating)creates a timer that fires every 0.8 seconds. You need to tick it each frame withtimer.tick(time.delta()), then checktimer.just_finished().
TimerMode::Repeatingmeans it auto-resets.TimerMode::Oncewould fire once and stop. Look at howAnimationTimerworks in yoursrc/characters/animation.rs- same concept!
The spawn system
Try writing this one yourself! Here’s what it needs to do:
- Check if it’s raining
- Tick the timer, return if it hasn’t fired yet
- Count existing puddles, return if at the cap (e.g., 20)
- Pick a random tile position
- Calculate the world coordinates
- Spawn a semi-transparent blue sprite there
#![allow(unused)]
fn main() {
pub fn spawn_puddles(
mut commands: Commands,
weather: Res<WeatherState>,
time: Res<Time>,
mut timer: ResMut<PuddleSpawnTimer>,
query: Query<&Puddle>,
) {
if !weather.is_raining {
return;
}
timer.0.tick(time.delta());
if !timer.0.just_finished() {
return;
}
if query.iter().count() >= 20 {
return;
}
// TODO: Pick a random tile position and spawn a puddle sprite
// Hint: Use fastrand::u32(0..25) for column, fastrand::u32(0..18) for row
// Hint: Puddle sprite should be slightly smaller than a tile (28x28)
// Hint: Use Z=12 so it appears above the map but below the player
// Hint: Use a semi-transparent blue color
}
}
The despawn system
When rain stops, all puddles are removed instantly. This is the simplest approach - for a more polished effect, see the “Puddle fade-out” challenge below where puddles gradually become transparent before being despawned.
#![allow(unused)]
fn main() {
pub fn despawn_puddles(
mut commands: Commands,
weather: Res<WeatherState>,
query: Query<Entity, With<Puddle>>,
) {
if weather.is_raining {
return;
}
for entity in query.iter() {
commands.entity(entity).despawn();
}
}
}
Register pub mod puddles; in mod.rs and add both systems.
💡 Think about: Why not modify the actual tilemap?
You could change actual tile sprites in the WFC-generated map to show water. But the WFC tilemap is complex and procedurally generated - mutating it would mean tracking which tiles you changed and reverting them later.
Instead, puddles are separate entities that float above the map visually. This is a common game dev pattern: layer visual effects on top rather than modifying the underlying data. It’s simpler and avoids bugs.
Reference: Z-Ordering Cheat Sheet
Here’s how your game’s visual layers stack up (higher Z = drawn on top):
Z=50 Raindrops (always visible, above everything)
Z=45 Rain overlay (darkens everything below it)
Z=20 Player character
Z=12 Puddles (above map, below player)
Z=0-5 Tilemap layers (dirt → grass → yellow_grass → water → props)
💡 Tip
If something isn’t visible, it’s probably behind something else. Add
println!("Z: {}", transform.translation.z)to debug. Or try setting Z=100 temporarily to see if the entity exists but is hidden.
Challenges: Take It Further
Once you have rain working, here are ideas to keep learning:
Easy
- Adjust rain intensity: Make
drops_per_frameandmax_dropsfields onWeatherStateso you can change density at runtime - Wind direction: Add a
windfield toWeatherStateand use it in the velocity calculation. Try making wind gradually change over time - Print puddle count: Add a system that prints the current puddle count when it changes - practice with Bevy’s
Changed<T>query filter
Medium
- Fade overlay in/out: Instead of instantly appearing, gradually change the overlay’s alpha from 0.0 to 0.3 over 1 second. You’ll need to query the overlay’s
Spriteand modify itscoloreach frame - Puddle fade-out: When rain stops, instead of instantly despawning puddles, fade their alpha to 0 and then despawn. Add a
FadingOutcomponent to track this - Sound effect: Add a looping rain sound using Bevy’s
AudioPlayercomponent. Check out: https://bevyengine.org/learn/quick-start/getting-started/audio/
Hard
- Splash particles: When a raindrop hits the ground (Y reaches the map surface), spawn a tiny “splash” entity that expands and fades out
- Puddles only on grass: Tag grass tiles with a marker component during WFC generation (using the
components_spawnercallback in yourSpawnablestruct insrc/map/assets.rs) and query those positions for puddle placement - Day/night + weather cycle: Create a time-of-day system that automatically triggers rain periodically, changes lighting, etc.
Rust Learning Opportunities
- Enums for weather types: Replace
is_raining: boolwithenum Weather { Clear, Rain, Storm }. Learn about Rust enums and pattern matching: https://doc.rust-lang.org/book/ch06-00-enums.html - Configuration file: Move weather constants (speed multiplier, max drops, colors) into a RON file like you did with characters. Practice with
serdedeserialization - System sets and ordering: Learn about Bevy’s system ordering to guarantee
toggle_rainruns before other weather systems: https://bevyengine.org/learn/quick-start/getting-started/system-order-of-execution/
Quick Checklist
Use this to track your progress:
- Created
src/weather/mod.rswithWeatherPlugin - Registered module and plugin in
src/main.rs -
cargo checkpasses - Created
src/weather/state.rswithWeatherState+toggle_rain - Press R toggles rain (check terminal output)
- Added
fastrand = "2"toCargo.toml - Created
src/weather/rain.rswith raindrop spawning - Raindrops appear when pressing R
- Added
move_raindropsanddespawn_raindrops - Rain falls and cleans up properly
- Created
src/weather/overlay.rs- screen darkens when raining - Modified
src/characters/movement.rs- player slows in rain - Created
src/weather/puddles.rs- puddles appear gradually - Full cycle works: R on → rain + dark + puddles + slow → R off → all gone
Note: All Bevy and Rust APIs used in this tutorial were introduced in earlier chapters. See the API Glossary for a complete reference with documentation links.
WFC Sockets Explained - A Mental Model
This guide explains how the socket system in src/map/rules.rs and src/map/sockets.rs works. It uses a plug-and-outlet analogy to make the constraint rules intuitive.
The Core Idea
The WFC (Wave Function Collapse) solver must fill every cell in the grid with exactly one tile model. Two tiles can be placed next to each other only if their touching faces have compatible sockets.
Think of sockets as plugs and outlets. Each face of a tile has a plug. Each connection rule declares which plugs fit which outlets. If the plug doesn’t fit, those two tiles can’t be neighbors.
The Grid is 3D
Even though the game looks 2D, the grid has three axes:
| Direction | Axis | Meaning |
|---|---|---|
x_pos / x_neg | Horizontal | Tile to the right / left |
y_pos / y_neg | Vertical | Tile above / below (on screen) |
z_pos / z_neg | Layers | What sits on top / what this sits on |
The five Z layers stack like a cake:
Z=4 Props [trees, rocks, plants]
Z=3 Water [ponds with shorelines]
Z=2 Yellow Grass [patches on green grass]
Z=1 Green Grass [patches on dirt]
Z=0 Dirt [solid foundation, always filled]
Socket Types by Role
1. void - “I am empty space”
A single socket shared across all layers above dirt. A void tile renders nothing - no sprite. It’s the “air” tile that fills cells where no terrain exists.
Connection rule: void ↔ void. Air can only neighbor air horizontally. This prevents terrain from appearing without proper edges.
Why it exists: The solver must fill every cell. On the grass layer (Z=1), maybe 80 out of 450 cells have grass. The other 370 still need a tile - that tile is void.
A column with grass: A column without grass:
Z=1 [grass_center] visible Z=1 [void] invisible
Z=0 [dirt] visible Z=0 [dirt] visible (bare dirt)
Dirt doesn’t have a void model because every Z=0 cell is always dirt - there’s no other option.
2. material - “Solid terrain, same type all around”
Each terrain type has its own material socket. A tile with material on all four horizontal faces is a center tile - fully surrounded by the same terrain.
Connection rule: material ↔ material (per terrain type). Grass center connects to grass center. Water center connects to water center. They never mix - grass material and water material are different plugs.
3. Transition Pairs - “Smooth edges”
These come in pairs and force the solver to place proper edge tiles between terrain and void:
void_and_grass- “void on one side, grass on the other”grass_and_void- “grass on one side, void on the other”
Connection rule: void_and_grass ↔ grass_and_void. These two plugs only fit each other.
This prevents hard blocky edges. Without transitions, you’d get:
BAD: [void] [grass_center] ← grass starts abruptly, no border
GOOD: [void] [grass_edge] [grass_center] ← smooth transition
The edge tile has void on one face and void_and_grass on the other. The void tile’s plug fits the void face. The grass center’s material won’t fit void, so the solver is forced to insert an edge.
4. layer_up / layer_down - “Vertical stacking”
These control which layers can sit on top of each other. The connections form a chain:
dirt.layer_up ↔ grass.layer_down
grass.layer_up ↔ yellow_grass.layer_down
yellow_grass.layer_up ↔ water.layer_down
water.layer_up ↔ props.layer_down
Each layer’s void model and terrain models all share the same layer_down socket for their layer, so dirt doesn’t care whether grass or void sits above it - both fit.
The Three Edge Shapes
Each terrain type (grass, yellow grass, water) defines three edge templates, then rotates each one four times to cover all orientations.
Outer Corner
The tip of a terrain peninsula. Two faces are void, two are transitions.
void
│
void ── [TL] ── void_and_grass
│
grass_and_void
From the code (green_grass_corner_out):
#![allow(unused)]
fn main() {
x_pos: terrain_sockets.grass.void_and_grass, // right: transition to grass
x_neg: terrain_sockets.void, // left: empty
y_pos: terrain_sockets.void, // up: empty
y_neg: terrain_sockets.grass.grass_and_void, // down: transition from grass
}
Rotated four times to create TL, BL, BR, TR corners - each paired with the matching sprite.
Inner Corner
The inside bend of an L-shaped terrain area. Two faces are solid material, two are transitions.
grass_and_void
│
material ── [tile] ── material
│
void_and_grass
Side Edge
A straight border. One face is material, the opposite is void, and the two remaining faces are the transition pair.
void
│
grass_and_void ── [tile] ── void_and_grass
│
material
Why Rotation Works
You define the socket pattern once for one orientation. Rotating 90° shifts which face has which socket. Each rotation gets paired with a different sprite (_tl, _bl, _br, _tr or _t, _l, _b, _r). The rotation moves the plugs around; the sprite visually matches.
How Props Stay Off Water
This is the trickiest pattern. The water layer’s void model has two plugs on its top face:
#![allow(unused)]
fn main() {
// Water VOID (no water here - land)
z_pos: vec![water.layer_up, water.ground_up] // two outlets on top
}
The water center tile has only one:
#![allow(unused)]
fn main() {
// Water CENTER (actual water)
z_pos: water.layer_up // one outlet on top
}
Props have two different bottom plugs:
- Props void uses
layer_down→ fitslayer_up(generic stacking) - Trees/rocks/plants use
props_down→ fitsground_up(land-only)
Land column: Water column:
Z=4 [tree] Z=4 [props void]
plug: props_down plug: layer_down
↕ ↕
outlet: ground_up ✓ outlet: layer_up ✓
Z=3 [water void] Z=3 [water center]
has ground_up NO ground_up
Tree fits on land ✓ Tree can't fit on water ✗
(no ground_up outlet)
The water center tile simply doesn’t offer the ground_up outlet. A tree’s props_down plug has nowhere to connect. The only tile whose bottom plug fits layer_up alone is the props void model - so above water, you always get empty space.
How Big Trees Stay Together
Big trees span two grid cells horizontally (left half + right half). A special socket glues them:
Left half: Right half:
x_pos: big_tree_1_base x_neg: big_tree_1_base
(right face) (left face)
└────── must match ──────┘
The connection rule says big_tree_1_base ↔ big_tree_1_base. No other tile has this plug, so if the solver places a left half, the only thing that fits to its right is the matching right half.
All other faces on both halves are void - the tree sits isolated with air around it.
Variant Mixing
The Vec in connection rules controls whether variants can mix. If you had a mossy trunk variant:
Mixing allowed (moss can appear on either half independently):
#![allow(unused)]
fn main() {
(big_tree_1_base, vec![big_tree_1_base, big_tree_1_mossy_base]),
(big_tree_1_mossy_base, vec![big_tree_1_base, big_tree_1_mossy_base]),
}
No mixing (mossy left must pair with mossy right):
#![allow(unused)]
fn main() {
(big_tree_1_base, vec![big_tree_1_base]),
(big_tree_1_mossy_base, vec![big_tree_1_mossy_base]),
}
The Vec answers: “does it look right if these two are next to each other?”
Yellow Grass: Reusing Another Layer’s Plugs
Yellow grass reuses green grass’s horizontal sockets (material, void_and_grass, grass_and_void) instead of defining its own. This means yellow grass edges automatically follow the same transition rules as green grass.
The only unique socket is yellow_grass_fill_down, which connects to grass.grass_fill_up. This ensures yellow grass only appears on top of solid green grass (center tiles), never on grass edges or void.
Connection Rules Summary
| Rule | What it means |
|---|---|
void ↔ void | Empty space neighbors empty space |
material ↔ material | Solid terrain neighbors same terrain |
void_and_grass ↔ grass_and_void | Transitions pair up for smooth edges |
dirt.layer_up ↔ grass.layer_down | Grass layer sits on dirt layer |
grass.grass_fill_up ↔ yellow_grass.yellow_grass_fill_down | Yellow grass only on solid green grass |
water.ground_up ↔ props.props_down | Props only on land (not water) |
big_tree_1_base ↔ big_tree_1_base | Tree halves must be side by side |
Quick Reference: Reading a Socket Block
When you see a block like this:
#![allow(unused)]
fn main() {
SocketsCartesian3D::Simple {
x_pos: terrain_sockets.grass.void_and_grass,
x_neg: terrain_sockets.void,
z_pos: terrain_sockets.grass.layer_up,
z_neg: terrain_sockets.grass.layer_down,
y_pos: terrain_sockets.void,
y_neg: terrain_sockets.grass.grass_and_void,
}
}
Read it as: “This tile has these plugs on each face. The solver will only place tiles next to it whose plugs are declared compatible in add_connections.”
- Horizontal faces (
x_pos,x_neg,y_pos,y_neg) → what can be next to this tile on the same layer - Vertical faces (
z_pos,z_neg) → what layer sits above/below this tile voidon a face → only other void tiles can be therematerialon a face → only same-terrain center tiles can be there- Transition socket on a face → only the matching transition partner can be there
Appendix B: API Glossary
All Bevy, Rust standard library, and third-party APIs referenced across this book, organized by category. Each entry links to official documentation and notes the chapter where it first appears.
Bevy: Core ECS
| API | Description | First Seen |
|---|---|---|
App | Application builder and entry point | Ch 1 |
Commands | Deferred command queue for spawning/despawning entities | Ch 1 |
Component | Derive macro to mark a type as attachable to entities | Ch 1 |
Plugin | Trait for modular code organization | Ch 1 |
Query<T> | System parameter for reading/writing entity components | Ch 1 |
Res<T> | Immutable access to a global resource | Ch 1 |
ResMut<T> | Mutable access to a global resource | Ch 1 |
Resource | Derive macro for global singleton data | Ch 1 |
Single<T> | Query that expects exactly one matching entity | Ch 1 |
Startup | Schedule that runs systems once at launch | Ch 1 |
Update | Schedule that runs systems every frame | Ch 1 |
With<T> | Query filter requiring a component’s presence | Ch 1 |
Changed<C> | Filter for modified components | Ch 4 |
Entity | Unique identifier for an ECS entity | Ch 5 |
OnEnter | One-shot schedule on state entry | Ch 4 |
OnExit | One-shot schedule on state exit | Ch 4 |
States | Derive for state machine enums | Ch 4 |
Without<T> | Query filter excluding entities with a component | Ch 4 |
in_state() | Run-condition gating systems to a state | Ch 4 |
.chain() | Enforces sequential system execution | Ch 4 |
.run_if() | Conditional system execution | Ch 4 |
Bevy: Rendering
| API | Description | First Seen |
|---|---|---|
Camera2d | Marker component for 2D camera entities | Ch 1 |
Color | Color representation (multiple color spaces) | Ch 1 |
Sprite | 2D image rendering component | Ch 1 |
Text2d | 2D text rendering component | Ch 1 |
TextColor | Text color component | Ch 1 |
TextFont | Font configuration for text rendering | Ch 1 |
TextureAtlas | Sprite atlas frame selection | Ch 1 |
TextureAtlasLayout | Defines how a spritesheet is sliced | Ch 1 |
Gizmos | Immediate-mode debug shape drawing | Ch 4 |
OrthographicProjection | Camera projection for zoom control | Ch 5 |
Bevy: Transforms & Math
| API | Description | First Seen |
|---|---|---|
Transform | Position, rotation, and scale of an entity | Ch 1 |
UVec2 | Unsigned integer 2D vector | Ch 1 |
Vec2 | 2D floating-point vector | Ch 1 |
Vec3 | 3D floating-point vector | Ch 1 |
URect | Unsigned integer rectangle | Ch 2 |
GlobalTransform | World-space transform (parent hierarchy) | Ch 5 |
IVec2 | Signed integer 2D vector | Ch 4 |
Bevy: Assets
| API | Description | First Seen |
|---|---|---|
AssetServer | Loads and manages game assets | Ch 1 |
Assets<T> | Typed storage for loaded assets | Ch 1 |
Handle<T> | Reference-counted pointer to a loaded asset | Ch 1 |
Asset | Marks a type as a loadable Bevy asset | Ch 3 |
TypePath | Runtime type path info (required for Asset) | Ch 3 |
Bevy: Input
| API | Description | First Seen |
|---|---|---|
ButtonInput<KeyCode> | Keyboard state resource | Ch 1 |
KeyCode | Identifies a physical keyboard key | Ch 1 |
Bevy: Time
| API | Description | First Seen |
|---|---|---|
Time | Frame timing and delta time resource | Ch 1 |
Timer | Countdown or repeating timer | Ch 1 |
TimerMode | Once or Repeating timer behavior | Ch 1 |
Bevy: Plugins & Configuration
| API | Description | First Seen |
|---|---|---|
AssetPlugin | Configures asset loading paths | Ch 1 |
ClearColor | Global background color resource | Ch 1 |
DefaultPlugins | Standard plugin group | Ch 1 |
ImagePlugin | Configures image loading defaults | Ch 1 |
Window | Window configuration | Ch 1 |
WindowPlugin | Configures window creation | Ch 1 |
WindowResolution | Window size configuration | Ch 2 |
MonitorSelection | Which monitor for fullscreen | Ch 5 |
WindowMode | Fullscreen/windowed/borderless mode | Ch 5 |
Rust Standard Library
| API | Description | First Seen |
|---|---|---|
Clone | Deep-copy capability | Ch 1 |
Copy | Cheap bitwise duplication | Ch 1 |
Debug | Debug formatting ({:?}) | Ch 1 |
Default | Default value generation | Ch 1 |
Deref | Transparent wrapper delegation | Ch 1 |
DerefMut | Mutable wrapper delegation | Ch 1 |
Eq | Total equality | Ch 1 |
Option<T> | Optional value (Some or None) | Ch 1 |
PartialEq | Equality comparison (==) | Ch 1 |
Result<T, E> | Success or failure return type | Ch 1 |
Vec<T> | Growable heap-allocated array | Ch 1 |
String | Owned heap-allocated UTF-8 string | Ch 2 |
HashMap<K, V> | Key-value container with O(1) lookup | Ch 3 |
Hash | Enables use as HashMap key | Ch 3 |
fmt::Display | User-facing {} string formatting | Ch 5 |
serde
| API | Description | First Seen |
|---|---|---|
Serialize | Serialization to RON, JSON, etc. | Ch 3 |
Deserialize | Deserialization from RON, JSON, etc. | Ch 3 |