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 & 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