Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

Chapter 2: Procedural Tilemap Generation with WFC

Chapter 3: Data-Driven Characters

Chapter 4: State Machines & Collision

Chapter 5: Inventory & Camera

Appendix A: Building a Rain System

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 lld or mold as 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:

PatternScheduleRunsUse Case
SetupStartupOnce at launchSpawn cameras, players, load assets
UpdateUpdateEvery frameInput 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 Plugin that 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: DefaultPlugins is 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 Player tag.”

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:

EntityComponents
#0Camera2d
#1Text2d("@"), 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 of Single. Iterate with for 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, not src/assets/. The tutorial overrides this via AssetPlugin. For new projects, prefer the default assets/ 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 in src/player.rs as 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:

  • enum vs struct: Use enum when you pick one option from a set (direction, weapon type, game state). Use struct when you need multiple fields together (position with x/y, stats with health/mana).
  • Deref / DerefMut: Lets AnimationTimer transparently behave like the inner Timer - you can call .tick(), .reset(), etc. directly on it.
  • Clone + Copy: Rust moves values by default (original becomes invalid). Copy opts into cheap bitwise duplication for small types. PartialEq / Eq enable == 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:

  • AssetServer performs 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:

  1. Detect if the facing direction changed → snap to the new row’s first frame.
  2. If the player just started moving → immediately advance one frame for responsive feedback.
  3. If continuously moving → advance frames on timer ticks (~10 FPS).
  4. 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). Plugin requires a build method.
  • (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()) guarantees move_player runs before animate_player every frame.


5. Rust Concepts Quick Reference

ConceptWhat It MeansWhen to Use
mutMutable binding - value can be changedAnytime you need to modify a variable
structGroup of named fieldsPosition, stats, config data
enumOne-of-many variantsDirections, states, weapon types
derive macroAuto-generate trait implementationsComponent, Debug, Clone, Copy, PartialEq
Deref / DerefMutTransparent wrapper - inner type’s methods available directlyNewtype pattern (e.g., AnimationTimer(Timer))
Ownership & MoveValues have one owner; assignment transfers ownershipDefault 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 ... elseDestructure or early-returnClean guard clauses
matchExhaustive pattern matchingEnums, Options, Results
..default()Fill remaining fields with defaultsStruct initialization shorthand

6. Bevy Memory & Performance Notes

Bevy’s ECS stores components in archetypes - tightly packed arrays of the same component types. This means:

  • Vec2 in Rust = exactly 8 bytes (two f32). 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.

Physics & Collision

CratePurposeNotes
avian2d2D physics engine for BevyThe modern standard; rigid bodies, colliders, joints. Replaces bevy_rapier2d as the go-to.
bevy_rapier2dRapier physics integrationBattle-tested, still widely used. Slightly more complex API than Avian.

Tilemap & World Building

CratePurposeNotes
bevy_ecs_tilemapPerformant ECS-native tilemapsGreat for large worlds; each tile is an entity.
bevy_ecs_ldtkLDtk level editor integrationVisual level design with auto-spawning of entities.
bevy_tiledTiled map editor integrationIf you prefer Tiled over LDtk.

Animation & VFX

CratePurposeNotes
bevy_spritesheet_animationDeclarative sprite animationsCleaner API than manual atlas cycling; supports animation clips and transitions.
bevy_hanabiGPU particle effectsDust trails, explosions, magic effects.
bevy_tweeningTweening / easing animationsSmooth UI transitions, camera movements, entity interpolation.

Input

CratePurposeNotes
leafwing-input-managerAction-based input mappingMaps physical keys to game actions. Supports gamepads, rebinding, chords. Essential for any serious game.

Camera

CratePurposeNotes
bevy_pancamPan/zoom 2D cameraQuick setup for scrollable worlds.
bevy_pixel_cameraPixel-perfect renderingPrevents sub-pixel blurriness in pixel art games.

UI & Debug

CratePurposeNotes
bevy-inspector-eguiRuntime entity/resource inspectorInvaluable for debugging - inspect and modify components live.
bevy_eguiegui integrationQuick debug UIs, settings panels, dev tools.
iyes_perf_uiFPS/diagnostics overlayFrame time, entity count, system timing.

Audio

CratePurposeNotes
bevy_kira_audioAdvanced audio with Kira backendSpatial audio, crossfading, audio buses. Much richer than Bevy’s built-in audio.

State Management & AI

CratePurposeNotes
big-brainUtility AI for NPCsScore-based decision making; great for enemy behaviors.
bonsai-btBehavior treesClassic game AI pattern for complex NPC logic.
seldom_stateState machine for entitiesClean state transitions for player/enemy states.

Networking

CratePurposeNotes
lightyearClient/server netcode for BevyPrediction, interpolation, lag compensation. The most complete Bevy networking solution.

Saving & Serialization

CratePurposeNotes
bevy_saveSave/load game stateSnapshots, rollbacks, scene serialization.

8. Tips for Further Improvement

Immediate Next Steps

  1. Add WASD support alongside arrow keys - check for both KeyCode::KeyW and KeyCode::ArrowUp in your input system for better ergonomics.
  2. Implement camera follow - instead of a static camera, make it track the player’s Transform with optional smoothing via lerp.
  3. Add a movement speed component - replace the hardcoded MOVE_SPEED constant with a MoveSpeed(f32) component on the player entity. This lets different entities have different speeds.
  4. Sprint mechanic - check input.pressed(KeyCode::ShiftLeft) and multiply speed by a factor.

Architecture Tips

  1. Use States for game flow - Bevy’s built-in States enum (e.g., MainMenu, InGame, Paused) controls which systems run when. Add systems with .run_if(in_state(GameState::InGame)).
  2. Use Events for communication - instead of checking flags every frame, use EventWriter<T> and EventReader<T> for things like “player attacked”, “item picked up”, “enemy died”.
  3. Organize by feature, not file type - instead of components.rs, systems.rs, prefer player.rs, enemy.rs, combat.rs each containing their own components + systems + plugin.
  4. 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

  1. Use SpriteBundle transforms wisely - avoid changing Transform.scale for sprite resizing; use the sprite’s custom_size field instead.
  2. Batch spawn with commands.spawn_batch() - when spawning many entities (enemies, particles), batch spawning is significantly faster.
  3. Run Bevy’s diagnostics - add LogDiagnosticsPlugin and FrameTimeDiagnosticsPlugin to track frame times during development.

Debug Workflow

  1. 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.
  2. Use info!(), warn!(), error!() - Bevy re-exports tracing macros. Prefer these over println!() 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 FixedUpdate for physics and anything that needs deterministic behavior. Use Update for input handling and visual updates.


API Quick Reference

APIs introduced in this chapter. See the API Glossary for all APIs across the book.

APICategoryDescription
AppCore ECSApplication builder and entry point
CommandsCore ECSDeferred command queue for spawning/despawning entities
ComponentCore ECSDerive macro to mark a type as attachable to entities
PluginCore ECSTrait for modular code organization
Query<T>Core ECSSystem parameter for reading/writing entity components
Res<T>Core ECSImmutable access to a global resource
ResMut<T>Core ECSMutable access to a global resource
ResourceCore ECSDerive macro for global singleton data
Single<T>Core ECSQuery that expects exactly one matching entity
StartupCore ECSSchedule that runs systems once at launch
UpdateCore ECSSchedule that runs systems every frame
With<T>Core ECSQuery filter requiring a component’s presence
Camera2dRenderingMarker component for 2D camera entities
ColorRenderingColor representation (multiple color spaces)
SpriteRendering2D image rendering component
Text2dRendering2D text rendering component
TextColorRenderingText color component
TextFontRenderingFont configuration for text rendering
TextureAtlasRenderingSprite atlas frame selection
TextureAtlasLayoutRenderingDefines how a spritesheet is sliced
TransformMathPosition, rotation, and scale of an entity
UVec2MathUnsigned integer 2D vector
Vec2Math2D floating-point vector
Vec3Math3D floating-point vector
AssetServerAssetsLoads and manages game assets
Assets<T>AssetsTyped storage for loaded assets
Handle<T>AssetsReference-counted pointer to a loaded asset
ButtonInput<KeyCode>InputKeyboard state resource
KeyCodeInputIdentifies a physical keyboard key
TimeTimeFrame timing and delta time resource
TimerTimeCountdown or repeating timer
TimerModeTimeOnce or Repeating timer behavior
AssetPluginPluginsConfigures asset loading paths
ClearColorPluginsGlobal background color resource
DefaultPluginsPluginsStandard plugin group
ImagePluginPluginsConfigures image loading defaults
WindowPluginsWindow configuration
WindowPluginPluginsConfigures window creation
CloneRust stdDeep-copy capability
CopyRust stdCheap bitwise duplication
DebugRust stdDebug formatting ({:?})
DefaultRust stdDefault value generation
DerefRust stdTransparent wrapper delegation
DerefMutRust stdMutable wrapper delegation
EqRust stdTotal equality
Option<T>Rust stdOptional value (Some or None)
PartialEqRust stdEquality comparison (==)
Result<T, E>Rust stdSuccess or failure return type
Vec<T>Rust stdGrowable 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

LayerZ-LevelPurposeWeight
Dirt0Base foundation - fills everything20.0
Green Grass1Patches on top of dirt with smooth edges5.0
Yellow Grass2Patches on top of green grass5.0
Water3Lakes/ponds with shoreline transitions0.02–0.2
Props4Trees, 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:

  1. Find the most constrained cell - the grid position with the fewest valid tile options remaining.
  2. Collapse it - pick one tile (weighted random selection).
  3. Propagate constraints - update all neighbors to remove now-invalid options.
  4. Repeat until the grid is full or a contradiction occurs.
  5. 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

TechniqueEffect
Same seed (RngMode::Seeded(42))Reproducible worlds for testing/sharing
Random seed (RngMode::RandomSeed)Unique world each run
Tile weightsBias 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_tilemaps is a fork of ghx_proc_gen maintained for Bevy 0.18 compatibility. For 3D support and debug tools, check the original ghx_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):

DirectionAxisMeaning in 2D
x_posRightTile to the right
x_negLeftTile to the left
y_posUpTile above
y_negDownTile below
z_posTop layerWhat sits on top of this tile (layer above)
z_negBottom layerWhat 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

  1. prepare_tilemap_handles(...) - loads the atlas PNG, registers each sprite’s rect in the layout.
  2. load_assets(...) - converts Vec<Vec<SpawnableAsset>> into ModelsAssets<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>>
}

T can be SocketsCartesian3D::Simple, SocketsCartesian3D::Multiple, or a ModelTemplate - anything that converts into a model template.


Step 5 - Rules (rules.rs)

Each layer follows the same pattern:

  1. Create a void model - empty space (no sprite) with void sockets horizontally.
  2. Create the center tile model - material sockets on all horizontal sides.
  3. Create edge templates - outer corners, inner corners, side edges.
  4. Rotate templates - Rot90, Rot180, Rot270 around Direction::ZForward to get all 4 orientations from 1 definition.
  5. Define connection rules - which sockets can connect to which.
  6. 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

LayerSpecial Behavior
DirtSingle model, weight 20.0. materialmaterial only.
Green Grass3 connection rules create organic patches. Uses SocketsCartesian3D::Multiple for z_pos to support both layer_up and grass_fill_up.
Yellow GrassReuses green grass’s material, void_and_grass, grass_and_void for horizontal connections. Only defines vertical sockets of its own.
WaterVery low weight (0.02) for small ponds. Void model uses Multiple sockets with both layer_up and ground_up to support props above.
PropsNo rotation needed. Multi-tile trees use GridDelta offsets and dedicated base sockets. props_downwater.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:

  1. Calls build_world() to get assets, models, and socket rules.
  2. Builds WFC Rules with Direction::ZForward rotation axis (2D game).
  3. Creates a CartesianGrid with dimensions (25, 18, 5) and no wrapping.
  4. Configures the generator: MinimumRemainingValue node heuristic + WeightedProbability model selection.
  5. Loads the sprite atlas and converts to renderable assets.
  6. 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

ConceptWhat It MeansExample
&'static strReference to a string literal that lives for the entire programSprite 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
ClosuresAnonymous functions that capture environment variables|_| {} (no-op), || socket_collection.create()
Generics (<T>)Type parameter - one function works with multiple typescreate_model<T> accepts any socket format
Trait bounds (where T: Into<...>)Constrain generic types to specific capabilitiesEnsures T can convert into ModelTemplate
str vs String&str = borrowed view of text; String = owned, heap-allocatedUse &str for constants, String for dynamic text
fn(...) vs closuresfn(...) is a function pointer (no captures); closures can capturecomponents_spawner: fn(&mut EntityCommands)
Implicit returnsLast expression without ; is the return valueself at end of with_grid_offset()
EncapsulationPrivate fields (no pub) accessed only through public methodsGridDelta fields are private; use with_grid_offset()

7. Quick Weight Tuning Guide

Weights control tile frequency. Small changes produce dramatic results:

Weight ValueEffect
WATER_WEIGHT = 0.02Small scattered ponds
WATER_WEIGHT = 0.07Large lake coverage
WATER_WEIGHT = 0.15Mostly water with land islands
dirt.with_weight(20.)Dirt dominates base layer (desired)
grass.with_weight(5.)Moderate grass coverage
PROPS_WEIGHT = 0.025Sparse prop placement
ROCKS_WEIGHT = 0.008Rocks 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

IssueWorkaround
White lines between tiles (Linux/Wayland)Run with WINIT_UNIX_BACKEND=x11 WAYLAND_DISPLAY= cargo run
Player walks on waterCollision detection not yet implemented (coming in later chapters)
WFC fails on large gridsKeep grid ≤ ~50×50 for now; chunking strategies covered later
Sprite blurrinessEnsure ImagePlugin::default_nearest() is set (not linear filtering)

Alternative/Complementary Proc-Gen

CratePurposeNotes
ghx_proc_genOriginal WFC library3D support, debug visualization, more advanced features than the fork
noisePerlin/Simplex noise generationGreat for height maps, temperature maps, biome distribution pre-seeding
bracket-noiseGame-focused noise libraryPart of the bracket-lib ecosystem; cellular automata, Perlin, etc.
bracket-pathfindingA* and Dijkstra pathfindingEssential for NPC navigation on generated terrain

Tilemap Rendering (Alternatives to Raw Sprites)

CratePurposeNotes
bevy_ecs_tilemapHigh-performance ECS-native tilemapsRenders thousands of tiles in a single draw call; much faster than individual sprite entities for large maps
bevy_ecs_ldtkLDtk level editor integrationHand-design parts of the world, proc-gen the rest

Collision & Physics (Next Steps)

CratePurposeNotes
avian2d2D physics for BevyAdd colliders to water tiles and props to prevent player walkthrough
bevy_rapier2dRapier physics integrationBattle-tested alternative to Avian

World Expansion

CratePurposeNotes
bevy_saveSave/load game statePersist generated worlds with their seeds
randRandom number generationSeeded RNG for reproducible world generation outside of the WFC library
bevy_asset_loaderDeclarative asset loadingLoading states with progress bars for large atlas files

Visual Polish

CratePurposeNotes
bevy_hanabiGPU particle effectsWater splashes, dust when walking, fireflies near trees
bevy_tweeningTweening/easing animationsAnimate water tiles with gentle bob, tree sway
bevy_pixel_cameraPixel-perfect renderingPrevents sub-pixel blurriness at any zoom level

10. Tips for Further Improvement

Immediate Enhancements

  1. 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.
  2. 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.
  3. Camera follow + bounds clamping - Make the camera follow the player but clamp to map edges so you never see void outside the world.
  4. Collision layer - Tag water and prop entities with a Collider component. Add a system that prevents player movement into collider tiles.

Architecture Improvements

  1. 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.
  2. 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.
  3. Separate render and logic layers - Use bevy_ecs_tilemap for rendering (single draw call) while keeping your WFC logic for generation. This dramatically improves performance for large maps.
  4. Seed display/input - Show the current world seed on screen. Let the player input a seed to share worlds.

Performance Notes

  1. 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_tilemap or sprite batching for anything beyond ~50×50.
  2. Generation time - WFC runs synchronously in setup_generator. For large grids, move generation to an async task and show a loading screen.
  3. Debug tools - Add bevy-inspector-egui to inspect tile entities at runtime. The original ghx_proc_gen has 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.

APICategoryDescription
URectMathUnsigned integer rectangle
WindowResolutionPluginsWindow size configuration
StringRust stdOwned 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.ron file 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 charactersEdit characters.ron - no recompile needed
Bug fix = update N functionsBug fix = update 1 function
No runtime switchingSwap 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. The ron feature enables loading .ron files as Bevy assets.
  • serde - Serialization/deserialization with #[derive(Serialize, Deserialize)] for structs that map to the .ron file.

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.rs from Chapter 1 and remove mod player; / PlayerPlugin from main.rs. The new characters module 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:

FeatureJSONRON
Key quotesRequiredOptional for identifiers
CommentsNot supported// and /* */ supported
Trailing commasSyntax errorAllowed
Rust typesLimited to JS typesNative tuples, structs, enums
Enum variantsEncoded as stringsFirst-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)

  1. initialize_player_character - adds components once asset is loaded (self-terminates)
  2. switch_character - swaps character data on digit key press
  3. move_player - reads input, updates Transform and AnimationController
  4. update_jump_state - detects jump animation completion, resets to Walk
  5. animate_characters - ticks timer, advances sprite frame index
  6. update_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 = 90
  • last = 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:

ConditionAction
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

KeyAction
Arrow keysMove in 4/8 directions
Shift (L or R)Sprint (uses run_speed_multiplier from config)
SpaceJump (plays once, then returns to Walk)
1–6Switch character

move_player Logic

  1. Read direction from arrow keys.
  2. If Space just pressed → set is_jumping, switch to AnimationType::Jump.
  3. If direction ≠ zero: normalize, apply speed × delta_time, update Transform and Facing.
  4. If not jumping: set is_moving = true, animation = Run (if Shift) or Walk.
  5. 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: Player marker + Transform + empty Sprite.

Stage 2 - initialize_player_character (Update, self-terminating):

  • Runs every frame but queries (With<Player>, Without<AnimationController>).
  • Once the .ron loads: 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:

  1. Validates index is within characters_list.characters.len().
  2. Updates CurrentCharacterIndex resource.
  3. Replaces CharacterEntry component with new character’s data (*current_entry = ...).
  4. 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

ConceptWhat It MeansExample in This Chapter
HashMap<K, V>Key-value lookup tableanimations: HashMap<AnimationType, AnimationDefinition>
Serialize / DeserializeConvert structs to/from text formats (RON, JSON)#[derive(Serialize, Deserialize)] on all config types
Asset + TypePath derivesRegister a struct as a loadable Bevy assetCharacterEntry and CharactersList
Method chainingSequential iterator operations.values().map(...).max().unwrap_or(0)
unwrap_or(default)Extract Option value or use fallbackmax().unwrap_or(0) - returns 0 if no animations
_ in pattern matchingIgnore 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 / DerefMutAuto-dereference a wrapper typeAnimationTimer(Timer) auto-delegates to inner Timer
Option<Res<T>>Resource that might not exist yetcharacters_list_res: Option<Res<CharactersListResource>>
Self-terminating queriesQuery with Without<C> that stops matching after inserting CQuery<Entity, (With<Player>, Without<AnimationController>)>
Component vs ResourceComponent = per-entity data; Resource = global singletonAnimationState (component) vs CurrentCharacterIndex (resource)
Default deriveAuto-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 valuesprite.texture_atlas.as_ref() (read) vs .as_mut() (write)

11. Tips for Further Improvement

Immediate Enhancements

  1. Add WASD support - extend MOVEMENT_KEYS array with (KeyCode::KeyA, Vec2::NEG_X), etc.
  2. Idle animation - add an AnimationType::Idle variant with a slow breathing/swaying cycle. Switch to it when !is_moving && !is_jumping instead of freezing on frame 0.
  3. Attack animations - add AnimationType::Attack to the enum and config. Use the same one-shot pattern as Jump (play once, return to Walk).
  4. Animation blending - instead of hard-cutting between animations, lerp the transition over 2–3 frames for smoother feel.
  5. Speed-scaled animation - dynamically adjust frame_time based on actual movement speed. Faster movement = faster walk cycle.

Architecture Improvements

  1. State machine (covered in Chapter 4) - replace boolean flags with a proper FSM for cleaner state transitions (Idle → Walk → Run → Jump → Attack → …).
  2. Character selection UI - replace number key switching with a visual character picker (portrait grid, scroll wheel).
  3. Hot-reload config - watch characters.ron for file changes and reload at runtime. Bevy’s asset server supports this with AssetServer::watch_for_changes().
  4. Event-driven transitions - emit AnimationChangedEvent when switching animations, letting other systems react (play sound effects, spawn particles).
  5. Component separation - split CharacterEntry into smaller components (CharacterStats, SpriteConfig, AnimationMap) for more granular ECS queries.

Performance Notes

  1. Atlas layout caching - create_character_atlas_layout creates a new layout handle on every character switch. Cache layouts per character to avoid redundant allocations.
  2. Query optimization - the animate_characters system queries all entities with animation components. For large entity counts, consider using change detection (Changed<AnimationController>) to skip unchanged entities.
  3. RON parsing - happens once at load time and is negligible. For hundreds of characters, consider binary serialization (bincode) for faster parsing.

Data & Serialization

CratePurposeNotes
serdeSerialize/deserialize Rust structsFoundation for RON, JSON, TOML, bincode, etc.
bevy_common_assetsAsset loaders for common formatsRON, JSON, TOML, YAML, CSV, MsgPack support
ronRON parser/writerDirect use for custom tooling; bevy_common_assets wraps it
bevy_asset_loaderDeclarative asset loading statesLoading screens, progress tracking, dependency management

Animation & State Machines

CratePurposeNotes
bevy_spritesheet_animationDeclarative sprite animationDefine clips + transitions without manual frame math
seldom_stateState machine for Bevy entitiesClean replacement for boolean flag state management
bevy_tweeningTween/easing animationsSmooth transitions between animation states

Character Systems

CratePurposeNotes
leafwing-input-managerInput abstraction & remappingMap actions to keys/gamepad instead of hardcoded KeyCode checks
bevy_rapier2d / avian2dPhysics & collisionNext chapter covers collision detection
bevy_eguiImmediate-mode UICharacter selection screens, stat displays, debug panels

Debug & Development

CratePurposeNotes
bevy-inspector-eguiRuntime entity/component inspectorInspect CharacterEntry and AnimationState live
iyes_perf_uiPerformance overlayFPS, 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.

APICategoryDescription
AssetAssetsMarks a type as a loadable Bevy asset
TypePathAssetsRuntime type path info (required for Asset)
HashMap<K, V>Rust stdKey-value container with O(1) lookup
HashRust stdEnables use as HashMap key
SerializeserdeSerialization to RON, JSON, etc.
DeserializeserdeDeserialization from RON, JSON, etc.

14. Complete Component Hierarchy

When fully initialized, the player entity has:

ComponentSourcePurpose
Playerspawn_playerMarker for queries (With<Player>)
Transformspawn_playerPosition, scale, z-order
Spriteinitialize_player_characterTexture atlas reference + current frame index
CharacterEntryinitialize_player_characterAll character data from .ron
AnimationControllerinitialize_player_characterCurrent animation type + facing direction
AnimationStateinitialize_player_characterMovement/jump flags + previous-frame flags
AnimationTimerinitialize_player_characterFrame 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 - LoadingPlayingPaused with OnEnter/OnExit schedules, loading screen, pause overlay
  • Character state machine - CharacterState enum (Idle/Walking/Running/Jumping) replacing boolean flags
  • Architecture refactor - movement.rs split into input.rs (what to do) + physics.rs (how to move)
  • Facing component - extracted from AnimationController into 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.rs for 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:

ScheduleWhen 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_jumpingCharacterState::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 mathN states = N enum variants + match
Adding a state = 2 new bools + update all if-chainsAdding 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.rsSingle responsibility
Facing inside AnimationControllerFacing as own componentMovement owns direction, animation owns clips
AnimationState (4 booleans)CharacterState enumImpossible states unrepresentable
animate_characters (one big system)on_state_change_update_animation + animations_playbackChange detection + per-frame playback separated
update_animation_flagsDeletedChanged<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:

MethodPurpose
world_to_grid(Vec2) → IVec2Screen position → tile coordinates
grid_to_world(i32, i32) → Vec2Tile coordinates → screen center of tile
is_walkable(x, y) → boolCan entities pass through this tile?
circle_intersects_tile(center, radius, gx, gy) → boolCircle-vs-rectangle overlap test
is_circle_clear(center, radius) → boolCheck all tiles under a circle for obstacles
sweep_circle(start, end, radius) → Vec2Swept 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))):

  1. Waits for WFC tiles to exist (early return if no tiles).
  2. Scans all TileMarker + Transform entities.
  3. Handles multi-layer: keeps only the topmost tile per (x,y) position (highest Z wins).
  4. Creates CollisionMap resource and populates it.
  5. 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:

AssetTileTypeNotes
DirtDirtWalkable base layer
Green/Yellow grassGrass/YellowGrassWalkable
Water + all water cornersWaterBlocked (edges become Shore)
Tree trunks (bottom sprites)TreeBlocked
Tree canopies (top sprites)NoneNo collision - player walks under canopy
RocksRockBlocked
PlantsGrassWalkable decorations
Tree stumpsTreeBlocked

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:

  1. Get current collider world position.
  2. Calculate desired position: current + velocity * dt.
  3. Call collision_map.sweep_circle(current, desired, radius) → get valid position.
  4. 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.

KeyAction
F3Toggle 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:

ComponentAdded ByPurpose
Playerspawn_playerMarker for input queries
Transformspawn_playerPosition, scale, Z-depth
Spriteinitialize_player_characterTexture atlas + current frame
CharacterEntryinitialize_player_characterCharacter data from .ron
AnimationControllerinitialize_player_characterCurrent animation type
CharacterStateinitialize_player_characterIdle/Walking/Running/Jumping
Velocityinitialize_player_characterMovement vector (px/sec)
Facinginitialize_player_characterUp/Down/Left/Right
Colliderinitialize_player_characterCircle radius + offset
AnimationTimerinitialize_player_characterFrame tick timing

12. Rust Concepts Introduced

ConceptWhat It MeansExample
States derive + init_stateRegister an enum as a Bevy state machine with OnEnter/OnExit#[derive(States)] on GameState
OnEnter(S) / OnExit(S)One-shot schedules on state transitionsOnExit(Loading) runs initialize_player_character once
.run_if(in_state(S))Gate systems to specific statesGameplay systems only run in Playing
Changed<C> query filterOnly matches entities whose component C was modified this frameChanged<CharacterState> triggers animation update
Match guards (_ if cond =>)Combine pattern matching with boolean conditions_ if wants_jump && current.is_grounded() => Jumping
matches! macroCheck if value matches a pattern, returns boolmatches!(self, Idle | Walking | Running)
#[cfg(debug_assertions)]Compile-time conditional - code only exists in debug buildsDebug collision overlay
#[inline]Hint compiler to inline small hot functionsxy_to_idx, in_bounds
Double dereference **textUnwrap Bevy’s change-tracking wrapper + dereference the inner reference**text = format!("Loading{}", ...)
Tuple struct (.0 access)Newtype wrapper with unnamed fieldDebugCollisionEnabled(pub bool)debug_enabled.0
HashMap::Entry APIEfficient insert-or-update without double lookupmatch layer_tracker.entry((x,y)) { Occupied/Vacant }
Builder patternChain .with_*() methods to configure a structSpawnableAsset::new("grass").with_tile_type(TileType::Grass)
Deref/DerefMut on newtypeAuto-delegate to inner type’s methodsVelocity(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 valuerun_if(resource_equals(CollisionMapBuilt(false)))

13. Controls Summary

KeyActionState Required
Arrow keysMove 4/8 directionsPlaying
Shift (L/R)SprintPlaying
SpaceJump (plays once)Playing, grounded
1–6Switch characterPlaying
EscapeToggle pausePlaying or Paused
F3Toggle collision debugPlaying (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_WEIGHT to 0.001 in build_water_layer to minimize water-spawn risk.

Tuning Collision Feel

ConstantEffect
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.25Smaller = smoother but more expensive. 0.25 is a good balance

Performance Notes

  • build_collision_map uses HashMap<(i32,i32), (TileType, f32)> for multi-layer dedup - runs once.
  • validate_movement calls sweep_circle per moving entity per frame. For many entities, consider spatial hashing.
  • Debug gizmos are free in release builds (#[cfg(debug_assertions)]).
  • Changed<CharacterState> and Changed<Transform> filters skip unchanged entities automatically.

Collision & Physics

CratePurposeNotes
avian2dFull 2D physics engine for BevyReplaces manual collision with rigid bodies, sensors, joints
bevy_rapier2dRapier physics integrationAlternative physics engine, more mature ecosystem
bevy_ecs_tilemapHigh-performance tilemap rendererBuilt-in tile queries, better than manual grid for large maps

State Machines

CratePurposeNotes
seldom_stateEntity state machines for BevyTransition conditions, state enter/exit hooks per-entity
bevy_stateBuilt-in (used in this chapter)States, OnEnter, OnExit, run_if(in_state(...))

Debug & Visualization

CratePurposeNotes
bevy-inspector-eguiRuntime entity/component inspectorLive-edit Collider, CharacterState, Velocity
bevy_mod_debugdumpVisualize system execution orderVerify .chain() ordering is correct

API Quick Reference

APIs introduced in this chapter. See the API Glossary for all APIs across the book.

APICategoryDescription
StatesCore ECSDerive for state machine enums
OnEnterCore ECSOne-shot schedule on state entry
OnExitCore ECSOne-shot schedule on state exit
in_state()Core ECSRun-condition gating systems to a state
.run_if()Core ECSConditional system execution
.chain()Core ECSEnforces sequential system execution
Changed<C>Core ECSFilter for modified components
Without<T>Core ECSQuery filter excluding entities with a component
GizmosRenderingImmediate-mode debug shape drawing
IVec2MathSigned 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

  • Pickable component + Inventory resource - distance-based collection with HashMap counting
  • ItemKind enum - Plant1–4, TreeStump with display names
  • SpawnableAsset gains .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, updated player and map modules

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_squared instead of distance to avoid expensive sqrt - compare dist² ≤ radius² instead.
  • Uses GlobalTransform for pickables (not Transform) because items may be children of other entities.
  • Collect-then-process pattern: gathers items into a Vec before 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:

AssetTileTypePickableResult
plant_1GrassPlant1Walkable + collectible
plant_2GrassPlant2Walkable + collectible
plant_3GrassPlant3Walkable + collectible
plant_4GrassPlant4Walkable + collectible
tree_stump_2TreeTreeStumpBlocking + collectible
tree_stump_1, tree_stump_3TreeNoneBlocking, not collectible
All other tilesVariousNoneNormal 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

ConfigChapter 4Chapter 5Why
TILE_SIZE32.064.0Zoomed-in view
PLAYER_SCALE0.81.2Larger player to match scaled world
COLLIDER_RADIUS16.024.0Proportional to new scale
ASSETS_SCALEVec3::ONEVec3::new(2.0, 2.0, 1.0)32px sprites rendered at 64px
NODE_SIZE_Z1.0 (local)1.0 (from config)Centralized
WindowFixed size (800×576)Borderless fullscreenImmersive camera view
BackgroundColor::WHITEColor::BLACKCleaner look

What Got Deleted from generate.rs

  • Local GRID_X, GRID_Y, TILE_SIZE constants → now imported from config::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:

  1. Many readers OR one writer, never both. You can have multiple &T (immutable borrows) or one &mut T (mutable borrow), but not both simultaneously.
  2. References must always be valid. A reference cannot outlive the data it points to (no dangling pointers).
  3. 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

ConceptWhat It MeansExample
fmt::Display traitImplement to enable {} formatting for custom typesimpl fmt::Display for ItemKind
HashMap::entry().or_insert()Get-or-create pattern for efficient upsertself.items.entry(kind).or_insert(0)
.truncate()Convert Vec3Vec2 (drop Z)transform.translation.truncate()
distance_squaredAvoid sqrt when only comparing distancespos.distance_squared(other) <= radius * radius
lerp (linear interpolation)Smooth transition between two valuescamera_pos.lerp(player_pos, factor)
.round() pixel snappingPrevent subpixel rendering artifactsnew_pos.x.round()
Changed<C> as optimizationSkip system entirely when component unchangedQuery<..., Changed<Transform>>
Without<T> in camera queryPrevent query from matching player entityQuery<..., (With<MainCamera>, Without<Player>)>
GlobalTransform vs TransformWorld-space position (accounts for parent hierarchy) vs localGlobalTransform for pickables that may be child entities
BorderlessFullscreenFullscreen without window chromeWindowMode::BorderlessFullscreen(MonitorSelection::Current)
Tuple matching in matchMatch on multiple values simultaneouslymatch (tile_type, pickable) { (Some(Grass), Some(Plant1)) => ... }

9. Controls Summary (Cumulative)

KeyActionState
Arrow keysMovePlaying
ShiftSprintPlaying
SpaceJumpPlaying, grounded
1–6Switch characterPlaying
EscapeToggle pausePlaying/Paused
F3Toggle collision debugPlaying (debug only)
Walk near itemsAuto-collect pickupsPlaying

10. Tips for Further Improvement

Inventory Enhancements

  1. Inventory UI - render collected items as icons with counts (Chapter 6 preview).
  2. Stack limits - cap item counts per type, show “Inventory Full” feedback.
  3. Drop items - reverse of pickup: spawn entity from inventory back into world.
  4. Crafting - combine items (3 Herbs + 1 Mushroom → Potion).
  5. Item rarity/weight - extend ItemKind or add ItemDefinition data file (RON).

Camera Enhancements

  1. Camera bounds clamping - prevent camera from showing areas beyond the map edge. Clamp camera position to [map_min + half_viewport, map_max - half_viewport].
  2. Camera shake - on pickup or collision, add temporary random offset for juice.
  3. Zoom controls - scroll wheel to adjust OrthographicProjection.scale.
  4. Look-ahead - offset camera slightly in the player’s movement direction for better visibility.
  5. Deadzone - only start following when player moves beyond a small radius from camera center.

Performance Notes

  1. 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.
  2. Camera Changed<Transform> - already optimized; zero cost when player is stationary.
  3. distance_squared - already optimized; avoids sqrt per-item per-frame.

Inventory & Items

CratePurposeNotes
bevy_eguiImmediate-mode UIQuick inventory panels, item tooltips
bevy_uiBuilt-in Bevy UINode-based layouts for inventory grids
serdeSerializationSave/load inventory to disk

Camera

CratePurposeNotes
bevy_pancamPan/zoom cameraDrag-to-pan, scroll-to-zoom, bounds clamping
bevy_pixel_cameraPixel-perfect renderingEliminates subpixel artifacts without manual .round()
iyes_perf_uiPerformance overlayMonitor FPS impact of pickup checks and camera updates

World Interaction

CratePurposeNotes
bevy_mod_pickingClick/hover detectionMouse-based item interaction alternative to proximity
leafwing-input-managerInput abstractionRebindable “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.

APICategoryDescription
EntityCore ECSUnique identifier for an ECS entity
GlobalTransformMathWorld-space transform (parent hierarchy)
OrthographicProjectionRenderingCamera projection for zoom control
WindowModePluginsFullscreen/windowed/borderless mode
MonitorSelectionPluginsWhich monitor for fullscreen
fmt::DisplayRust stdUser-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

  1. Step 1: Set Up the Weather Module
  2. Step 2: Create the Weather State
  3. Step 3: Make It Rain - Spawning Raindrops
  4. Step 4: Move and Clean Up Raindrops
  5. Step 5: Darken the Screen
  6. Step 6: Slow the Player
  7. Step 7: Spawn Puddles
  8. Reference: Z-Ordering Cheat Sheet
  9. 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

  1. Create the folder src/weather/
  2. Create the file src/weather/mod.rs
  3. 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 Plugin in Bevy is just a way to group related setup code. When you call .add_plugins(MyPlugin), Bevy calls your build() method, which is where you register systems, resources, and events.

Look at how CharactersPlugin in src/characters/mod.rs does this - your WeatherPlugin will 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

  1. Create src/weather/state.rs
  2. Define a WeatherState resource
  3. Add a system that toggles rain with the R key
  4. 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> vs ResMut<T>

  • Res<T> gives you read-only access to a resource
  • ResMut<T> gives you mutable (read-write) access

Bevy 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_pressed vs pressed

  • just_pressed() - true only on the first frame the key goes down
  • pressed() - true every frame while the key is held

For a toggle, you want just_pressed. For movement (like your arrow keys), you want pressed. Look at how src/characters/movement.rs uses 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_resource vs insert_resource

  • init_resource::<T>() - creates the resource using its Default impl
  • insert_resource(my_value) - inserts a specific value you provide

Since we implemented Default for WeatherState, init_resource is 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

  • rand crate - the standard Rust RNG library, more features but heavier
  • bevy_rand - Bevy plugin that integrates rand as a resource
  • fastrand - tiny, fast, no dependencies. Perfect for our use case.

📖 Check out the rand book if you want to learn about RNG in Rust: https://rust-random.github.io/book/

What to do

  1. Create src/weather/rain.rs
  2. Define Raindrop and RaindropVelocity components
  3. Write a spawn_raindrops system

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 Raindrop or your existing Player) 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 at TilemapGenerator in src/map/generate.rs and Player in src/characters/movement.rs.

The spawn system

Think about what this system needs to do each frame:

  1. Check if it’s raining (if not, do nothing)
  2. Count existing raindrops (don’t spawn too many)
  3. 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.rs does the same thing:

#![allow(unused)]
fn main() {
commands.spawn((Player, Transform::from_translation(...), Sprite::default()));
}

💡 Understanding Sprite without a texture

Normally you load an image for a sprite. But you can also set custom_size and color to 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:

  1. Move drops downward each frame
  2. 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.rs line 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 Transform and RaindropVelocity components…
  • But only from entities that also have a Raindrop component

The With<Raindrop> is a filter - it narrows down which entities match. You don’t need the Raindrop data itself, just its presence. Compare this to how move_player uses With<Player> in src/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: Entity

When 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_map in src/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 rain
  • max_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

  1. Create src/weather/overlay.rs
  2. 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 Transform controls 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:

  1. Import WeatherState
  2. Add it as a system parameter
  3. 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.7 in 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

  1. Create src/weather/puddles.rs
  2. Use a timer to control spawn rate (not every frame!)
  3. 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 with timer.tick(time.delta()), then check timer.just_finished().

TimerMode::Repeating means it auto-resets. TimerMode::Once would fire once and stop. Look at how AnimationTimer works in your src/characters/animation.rs - same concept!

The spawn system

Try writing this one yourself! Here’s what it needs to do:

  1. Check if it’s raining
  2. Tick the timer, return if it hasn’t fired yet
  3. Count existing puddles, return if at the cap (e.g., 20)
  4. Pick a random tile position
  5. Calculate the world coordinates
  6. 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_frame and max_drops fields on WeatherState so you can change density at runtime
  • Wind direction: Add a wind field to WeatherState and 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 Sprite and modify its color each frame
  • Puddle fade-out: When rain stops, instead of instantly despawning puddles, fade their alpha to 0 and then despawn. Add a FadingOut component to track this
  • Sound effect: Add a looping rain sound using Bevy’s AudioPlayer component. 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_spawner callback in your Spawnable struct in src/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: bool with enum 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 serde deserialization
  • System sets and ordering: Learn about Bevy’s system ordering to guarantee toggle_rain runs 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.rs with WeatherPlugin
  • Registered module and plugin in src/main.rs
  • cargo check passes
  • Created src/weather/state.rs with WeatherState + toggle_rain
  • Press R toggles rain (check terminal output)
  • Added fastrand = "2" to Cargo.toml
  • Created src/weather/rain.rs with raindrop spawning
  • Raindrops appear when pressing R
  • Added move_raindrops and despawn_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:

DirectionAxisMeaning
x_pos / x_negHorizontalTile to the right / left
y_pos / y_negVerticalTile above / below (on screen)
z_pos / z_negLayersWhat 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 → fits layer_up (generic stacking)
  • Trees/rocks/plants use props_down → fits ground_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

RuleWhat it means
void ↔ voidEmpty space neighbors empty space
material ↔ materialSolid terrain neighbors same terrain
void_and_grass ↔ grass_and_voidTransitions pair up for smooth edges
dirt.layer_up ↔ grass.layer_downGrass layer sits on dirt layer
grass.grass_fill_up ↔ yellow_grass.yellow_grass_fill_downYellow grass only on solid green grass
water.ground_up ↔ props.props_downProps only on land (not water)
big_tree_1_base ↔ big_tree_1_baseTree 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
  • void on a face → only other void tiles can be there
  • material on 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

APIDescriptionFirst Seen
AppApplication builder and entry pointCh 1
CommandsDeferred command queue for spawning/despawning entitiesCh 1
ComponentDerive macro to mark a type as attachable to entitiesCh 1
PluginTrait for modular code organizationCh 1
Query<T>System parameter for reading/writing entity componentsCh 1
Res<T>Immutable access to a global resourceCh 1
ResMut<T>Mutable access to a global resourceCh 1
ResourceDerive macro for global singleton dataCh 1
Single<T>Query that expects exactly one matching entityCh 1
StartupSchedule that runs systems once at launchCh 1
UpdateSchedule that runs systems every frameCh 1
With<T>Query filter requiring a component’s presenceCh 1
Changed<C>Filter for modified componentsCh 4
EntityUnique identifier for an ECS entityCh 5
OnEnterOne-shot schedule on state entryCh 4
OnExitOne-shot schedule on state exitCh 4
StatesDerive for state machine enumsCh 4
Without<T>Query filter excluding entities with a componentCh 4
in_state()Run-condition gating systems to a stateCh 4
.chain()Enforces sequential system executionCh 4
.run_if()Conditional system executionCh 4

Bevy: Rendering

APIDescriptionFirst Seen
Camera2dMarker component for 2D camera entitiesCh 1
ColorColor representation (multiple color spaces)Ch 1
Sprite2D image rendering componentCh 1
Text2d2D text rendering componentCh 1
TextColorText color componentCh 1
TextFontFont configuration for text renderingCh 1
TextureAtlasSprite atlas frame selectionCh 1
TextureAtlasLayoutDefines how a spritesheet is slicedCh 1
GizmosImmediate-mode debug shape drawingCh 4
OrthographicProjectionCamera projection for zoom controlCh 5

Bevy: Transforms & Math

APIDescriptionFirst Seen
TransformPosition, rotation, and scale of an entityCh 1
UVec2Unsigned integer 2D vectorCh 1
Vec22D floating-point vectorCh 1
Vec33D floating-point vectorCh 1
URectUnsigned integer rectangleCh 2
GlobalTransformWorld-space transform (parent hierarchy)Ch 5
IVec2Signed integer 2D vectorCh 4

Bevy: Assets

APIDescriptionFirst Seen
AssetServerLoads and manages game assetsCh 1
Assets<T>Typed storage for loaded assetsCh 1
Handle<T>Reference-counted pointer to a loaded assetCh 1
AssetMarks a type as a loadable Bevy assetCh 3
TypePathRuntime type path info (required for Asset)Ch 3

Bevy: Input

APIDescriptionFirst Seen
ButtonInput<KeyCode>Keyboard state resourceCh 1
KeyCodeIdentifies a physical keyboard keyCh 1

Bevy: Time

APIDescriptionFirst Seen
TimeFrame timing and delta time resourceCh 1
TimerCountdown or repeating timerCh 1
TimerModeOnce or Repeating timer behaviorCh 1

Bevy: Plugins & Configuration

APIDescriptionFirst Seen
AssetPluginConfigures asset loading pathsCh 1
ClearColorGlobal background color resourceCh 1
DefaultPluginsStandard plugin groupCh 1
ImagePluginConfigures image loading defaultsCh 1
WindowWindow configurationCh 1
WindowPluginConfigures window creationCh 1
WindowResolutionWindow size configurationCh 2
MonitorSelectionWhich monitor for fullscreenCh 5
WindowModeFullscreen/windowed/borderless modeCh 5

Rust Standard Library

APIDescriptionFirst Seen
CloneDeep-copy capabilityCh 1
CopyCheap bitwise duplicationCh 1
DebugDebug formatting ({:?})Ch 1
DefaultDefault value generationCh 1
DerefTransparent wrapper delegationCh 1
DerefMutMutable wrapper delegationCh 1
EqTotal equalityCh 1
Option<T>Optional value (Some or None)Ch 1
PartialEqEquality comparison (==)Ch 1
Result<T, E>Success or failure return typeCh 1
Vec<T>Growable heap-allocated arrayCh 1
StringOwned heap-allocated UTF-8 stringCh 2
HashMap<K, V>Key-value container with O(1) lookupCh 3
HashEnables use as HashMap keyCh 3
fmt::DisplayUser-facing {} string formattingCh 5

serde

APIDescriptionFirst Seen
SerializeSerialization to RON, JSON, etc.Ch 3
DeserializeDeserialization from RON, JSON, etc.Ch 3