Welcome

Welcome to the introductory workshop for the Bevy Engine.

You can access it at https://vleue.github.io/bevy_workshop-rustweek-2025/.

qrcode

By the end of this workshop, you will have a comprehensive understanding of how Bevy works and will have created a clone of the Asteroid game.

Target Audience

This workshop is designed for individuals who want to deepen their understanding of Bevy basics and already have a good grasp of Rust.

To get started with Rust, explore these free resources:

This workshop will not use any third-party plugins and will not delve deeply into rendering.

Credits

Assets used are from Kenney's Space Shooter Redux, or were created specifically for this workshop.

Use A and D to turn left and right, W to move forward, and space to fire lasers!

Setup

Clone the repository

git clone https://github.com/vleue/bevy_workshop-rustweek-2025

Environment setup

Option 1 is recommended if your local machine supports it. This workshop won't be GPU heavy so most hardware configurations should support running it.

Option 1: Local Setup

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
cargo build

Option 2: Docker Setup

This option can be interesting if you can't install dependencies on your machine, or the setup fails for some obscure reason. Instead of running natively, the workshop will run in your browser using wasm and WebGL2, delegating most OS/hardware integration to the browser.

Run a docker image from scratch

docker run -it -v `pwd`:/workspace -p 4000:4000 rust:1.82-bullseye /bin/bash
rustup target add wasm32-unknown-unknown
# install cargo binstall
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
# install a few helpers
cargo binstall --no-confirm wasm-bindgen-cli cargo-watch basic-http-server

cd /workspace
# serve the wasm in the background
basic-http-server wasm 2> /dev/null &
# build for wasm
cargo build --release --target wasm32-unknown-unknown && wasm-bindgen --out-dir wasm --out-name workshop --target web target/wasm32-unknown-unknown/release/bevy_workshop-rustweek-2025.wasm

Or use a prebuilt docker image

It will be a bigger initial download but the first build is already done

docker run -it -v `pwd`:/workspace -p 4000:4000 ghcr.io/vleue/bevy_workshop-rustweek-2025 /bin/bash

# Copy the prebuilt target folder
cp -r bevy_workshop-rustweek-2025/target /workspace/target

cd /workspace
# serve the wasm in the background
basic-http-server wasm 2> /dev/null &
# build for wasm
cargo build --release --target wasm32-unknown-unknown && wasm-bindgen --out-dir wasm --out-name workshop --target web target/wasm32-unknown-unknown/release/bevy_workshop-rustweek-2025.wasm

Option 3: Use GitHub Codespace

Go to https://github.com/codespaces/new/vleue/bevy_workshop-rustweek-2025, it will use a prebuilt image with everything needed to work in wasm. Increase the number of core as much as you're comfortable with. GitHub free tier of codespace is 120 core-hours per month, so with an 8-core machine you'll have 15 hours.

This option uses more bandwidth as you'll download the wasm file from the internet on every rebuild.

Terms

Explore key terms and concepts used in Bevy. We'll delve deeper into how they are applied throughout the workshop.

ECS

E for Entity

An Entity is a unique identifier that represents a general-purpose object in the ECS. It acts as a pointer.

C for Component

A Component is a data structure that holds information or attributes of an entity. Components are used to store the state and data of entities.

S for System

A System is a function that operates on entities with specific components. Systems define the behavior and logic of the ECS by processing entities' components.

Another way to think about ECS

It's a database!

Entities are the index, components are the columns and systems are procedural queries.

Bevy Concepts

Application

The Application is the main entry point of Bevy. It manages the game loop, schedules systems, and handles resources and events. It exists only during setup, and is not available once the game loop started.

Plugin

A Plugin is a modular piece of functionality that can be added to a Bevy app. It encapsulates systems, resources, and configuration. It exists only at build time.

World

The World is a data structure that stores all entities and their components. It provides methods to query and manipulate entities and components.

Query

A Query is used to access entities and their components in a World. It allows systems to filter and iterate over entities with specific component sets.

Commands

Commands are used to schedule changes to the World, such as adding or removing entities and components. They are executed later during the same frame, after the system that generated them ended.

Resource

A Resource is a globally accessible data structure that is not tied to any specific entity. It is used to store shared data and state.

Event

An Event is a message that can be sent and received by systems. Events are used to communicate between systems and decouple their logic.

Observer

An Observer is a system that reacts to changes in the World, such as component modifications or entity creation. It is used to implement reactive behavior.

Relations

Relations are a way to link entities together. The most common is the parent / children relation, which propagates things like position and visibility.

Introduction to Bevy

Embark on your game development journey by creating a captivating splash screen. In this section, you will:

  • Set up a new Bevy application
  • Integrate essential plugins
  • Develop a system to spawn entities with components
  • Use states to minimize boilerplate code
  • Refactor your codebase with plugins for better organization

Switch to the branch:

git checkout 05-intro-to-bevy

The Application

The initial goal is to open a window using Bevy!

Empty Application

Let's start a new project with Bevy

cargo new bevy_workshop-rustweek-2025
cd bevy_workshop-rustweek-2025

We can add Bevy 0.16 with the default features enabled:

cargo add bevy@0.16

Updating crates.io index
  Adding bevy v0.16 to dependencies
         Features as of v0.16.0:
         41 activated features
         68 deactivated features
Updating crates.io index
 Locking 468 packages to latest Rust 1.86.0 compatible versions

Bevy exposes a lot of features, 109 for the 0.16! The full list of features is available in the documentation. It is important to disable default features and only enable the ones you need. This will improve performance, compilation time and reduce binary size.

For this workshop, we'll use the following features:

cargo add bevy@0.16 --no-default-features --features "bevy_asset,bevy_audio,bevy_core_pipeline,bevy_render,bevy_sprite,bevy_state,bevy_text,bevy_ui,bevy_winit,default_font,multi_threaded,bevy_gizmos,wav,png,x11,wayland,webgl2"

You can add all the dependencies used in this workshop now to avoid recompilations later:

cargo add bevy@0.16 --no-default-features --features "bevy_asset,bevy_audio,bevy_core_pipeline,bevy_render,bevy_sprite,bevy_state,bevy_text,bevy_ui,bevy_winit,default_font,multi_threaded,bevy_gizmos,wav,png,x11,wayland,webgl2"
cargo add avian2d
cargo add bevy_enhanced_input
cargo add bevy_enoki
cargo add rand@0.8
cargo add thiserror

This is the most basic Bevy application. It will exit immediately upon running and perform no actions.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new().run();
}

Default Bevy Plugins

Default plugins are added to handle windowing, rendering, input, audio, and more. This application opens a window and then does nothing.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins)
        .run();
}

Plugins can be configured; in this example, we set a custom title for the window.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Workshop".into(),
                ..default()
            }),
            ..default()
        }))
        .run();
}

Systems and Schedules

A splash screen needs to display something, so let's show a title in the open window.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Workshop".into(),
                ..default()
            }),
            ..default()
        }))
        // add a system that executes once at startup
        .add_systems(Startup, display_title)
        .run();
}

fn display_title(mut commands: Commands) {
    commands.spawn(Camera2d);

    commands.spawn((
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            flex_direction: FlexDirection::Column,
            ..default()
        },
        children![
            (
                Text::new("Bevy Workshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
            ),
            (
                Text::new("Rust Week 2025"),
                TextFont {
                    font_size: 100.0,
                    ..default()
                },
            )
        ],
    ));
}

Schedules

The Startup schedule is used for tasks that need to occur only once during application startup.

Other common schedules include PreUpdate, Update, and PostUpdate, along with their fixed counterparts: FixedPreUpdate, FixedUpdate, and FixedPostUpdate.

Systems in the Update schedule execute every frame. With vsync enabled, this is typically driven by your screen's refresh rate, commonly 60fps, with some Macs running at 120fps. Systems in the FixedUpdate schedule execute at a configurable, fixed frequency, defaulting to 64Hz. Most game logic should occur within these schedules.

Pre* and Post* schedules are useful for preparation and cleanup/propagation tasks surrounding game logic.

Systems

Systems are functions whose parameters must implement the SystemParam trait. These parameters are provided through dependency injection based on their type.

If you want more details on how this works, you can find them here: Dependency Injection like Bevy Engine from Scratch

Commands

Commands are one way of modifying the game world, without risking to encounter double borrow of the world. You can add, mutate, or remove entities and components. They are not executed straight away, but at sync points between systems.

Hierarchy

Bevy has the concept of hierarchy, with Parent / Children relationship. This is heavily used in UI for layout, or in animations.

When an entity is a child of another, its position is relative to its parent. It's also possible to remove a complete branch of a hierarchy at once.

The children! macro is an helper to reduce boilerplate when spawning an entity with children. It handles automatically the Children / Parent components, and keeps the code simpler.

Side note: UI

The startup system in the example above spawns text. It first spawns a node entity, which functions similarly to a <div> HTML tag, used to center the text, and then spawns the text itself as a child.

Bevy offers two layout strategies for UI: Flexbox and CSS Grids.

Updating the World

A key characteristic of a splash screen is that it doesn't stay forever. Let's remove the title after two seconds.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Workshop".into(),
                ..default()
            }),
            ..default()
        }))
        .add_systems(Startup, display_title)
        .add_systems(Update, remove_title)
        .run();
}

fn display_title(mut commands: Commands) {
    commands.spawn(Camera2d);

    commands.spawn((
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            flex_direction: FlexDirection::Column,
            ..default()
        },
        children![
            (
                Text::new("Bevy Workshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
            ),
            (
                Text::new("Rust Week 2025"),
                TextFont {
                    font_size: 100.0,
                    ..default()
                },
            )
        ],
    ));

    commands.insert_resource(SplashScreenTimer(Timer::from_seconds(2.0, TimerMode::Once)));
}

#[derive(Resource)]
struct SplashScreenTimer(Timer);

fn remove_title(
    time: Res<Time>,
    mut timer: ResMut<SplashScreenTimer>,
    mut commands: Commands,
    nodes: Query<Entity, With<Node>>
) {
    if timer.0.tick(time.delta()).just_finished() {
        for entity in &nodes {
            commands.entity(entity).despawn();
        }
    }
}

Resources

Resources are used to store singletons in the world, based on their type.

Here, we're adding a resource SplashScreenTimer that simply holds a Timer.

Queries

Queries are used to access entities and their components in the world and can be filtered.

In the remove_title system, we're using a Query that requests access only to the Entity, filtering on the component Node, which is a basic component shared among all UI elements.

Mutable vs. Immutable Access

The remove_title system accesses two resources:

  • Time, provided by Bevy, in an immutable way
  • SplashScreenTimer, our custom resource, in a mutable way; the timer in this resource will be ticked, so we need to modify it

As the world continues to hold ownership of data, systems have access to references. Only one system accessing a given part of the world mutably can run at a time. Systems that access different parts mutably, or the same parts immutably, can run in parallel.

States

Bevy provides an abstraction and helpers to control systems that execute based on the application's state, aptly named "states."

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Workshop".into(),
                ..default()
            }),
            ..default()
        }))
        .init_state::<GameState>()
        .enable_state_scoped_entities::<GameState>()
        .add_systems(OnEnter(GameState::Splash), display_title)
        .add_systems(Update, switch_to_menu.run_if(in_state(GameState::Splash)))
        .run();
}


#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Splash,
    StartMenu,
}

#[derive(Resource)]
struct SplashScreenTimer(Timer);

fn display_title(mut commands: Commands) {
    commands.spawn(Camera2d);

    commands.spawn((
        Node {
            width: Val::Percent(100.0),
            height: Val::Percent(100.0),
            align_items: AlignItems::Center,
            justify_content: JustifyContent::Center,
            flex_direction: FlexDirection::Column,
            ..default()
        },
        children![
            (
                Text::new("Bevy Workshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
            ),
            (
                Text::new("Rust Week 2025"),
                TextFont {
                    font_size: 100.0,
                    ..default()
                },
            )
        ],
        StateScoped(GameState::Splash),
    ));

    commands.insert_resource(SplashScreenTimer(Timer::from_seconds(2.0, TimerMode::Once)));
}

fn switch_to_menu(
    mut next: ResMut<NextState<GameState>>,
    mut timer: ResMut<SplashScreenTimer>,
    time: Res<Time>,
) {
    if timer.0.tick(time.delta()).just_finished() {
        next.set(GameState::StartMenu);
    }
}

State-Based Schedules

When using states, additional schedules are available: OnEnter, OnExit, and OnTransition.

Changing States

States can be changed using the NextState resource.

State-Scoped Entities

By adding the StateScoped component, all entities and their hierarchy marked with this component will be despawned when exiting the state.

Plugins

Plugins are used for code organization, often in their own files.

extern crate bevy;
use bevy::prelude::*;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Bevy Workshop".into(),
                ..default()
            }),
            ..default()
        }))
        .init_state::<GameState>()
        .enable_state_scoped_entities::<GameState>()
        .add_plugins(splash::SplashPlugin)           // adding our new plugin
        .run();
}

#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Splash,
    StartMenu,
}

mod splash {
    use bevy::prelude::*;

    use crate::GameState;

    pub struct SplashPlugin;

    impl Plugin for SplashPlugin {
        fn build(&self, app: &mut App) {
            app.add_systems(OnEnter(GameState::Splash), display_title)
                .add_systems(Update, switch_to_menu.run_if(in_state(GameState::Splash)));
        }
    }

    fn display_title(mut commands: Commands) {
        commands.spawn(Camera2d);

        commands.spawn((
            Node {
                width: Val::Percent(100.0),
                height: Val::Percent(100.0),
                align_items: AlignItems::Center,
                justify_content: JustifyContent::Center,
                flex_direction: FlexDirection::Column,
                ..default()
            },
            children![
                (
                    Text::new("Bevy Workshop"),
                    TextFont {
                        font_size: 130.0,
                        ..default()
                    },
                ),
                (
                    Text::new("Rust Week 2025"),
                    TextFont {
                        font_size: 100.0,
                        ..default()
                    },
                )
            ],
            StateScoped(GameState::Splash),
        ));

        commands.insert_resource(SplashScreenTimer(Timer::from_seconds(2.0, TimerMode::Once)));
    }

    #[derive(Resource)]
    struct SplashScreenTimer(Timer);

    fn switch_to_menu(
        mut next: ResMut<NextState<GameState>>,
        mut timer: ResMut<SplashScreenTimer>,
        time: Res<Time>,
    ) {
        if timer.0.tick(time.delta()).just_finished() {
            next.set(GameState::StartMenu);
        }
    }
}

For most cases, a plugin can be a free function:

extern crate bevy;
use bevy::prelude::*;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Splash,
}
fn main() {
    App::new()
        // ...
        .add_plugins(splash::splash_plugin)           // adding our new plugin
        .run();
}

mod splash {
    use bevy::prelude::*;
    use crate::GameState;
    fn display_title() {}
    fn load_assets() {}
    fn switch_to_menu() {}
    pub fn splash_plugin(app: &mut App) {
        app.add_systems(OnEnter(GameState::Splash), display_title)
            .add_systems(Update, switch_to_menu.run_if(in_state(GameState::Splash)));
    }
}

Exercises

Don't forget to checkout the branch:

git checkout 05-intro-to-bevy

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/0-zero..05-intro-to-bevy

Adding a Start Menu

We'll add a new plugin to handle the start menu. It will be very similar to the splash screen plugin, with different text and with a different condition to change state.

Tips:

  • Create a new file for the new plugin, you can copy splash.rs as a starting point

  • Change the state conditions and state scopes to GameState::StartMenu

  • Modify the text to display a start menu instead of a splash screen

  • Create a new variant of GameState for the game

  • Modify the condition to change state to check for a key press instead of a timer

    • The system parameter for key press is Res<ButtonInput<KeyCode>>
    • Checking that any key was just pressed can be done with keyboard.get_just_pressed().next().is_some()
  • Add the new plugin to the application

Progress Report

Let's review what was done: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/0-zero..05-intro-to-bevy

What You've learned

  • Bevy dependencies, and its features
    • Disabling default features for build time and size, and for runtime performances
    • Knowing the list of features
  • Application creation and adding Bevy default plugins
  • Schedules and adding systems
  • Basic use of commands and queries
    • The Commands queue
    • To issue a commanddocs.rs/bevy/0.16.0/
    • And using a Query to access components
  • States, and running system only on a state or during state transition
  • Code organization with plugins

Basic Game

By the end of this section, you'll be able to move the player and implement game loss conditions.

You will:

  • Load and display sprites
  • Respond to user input
  • Query entities and components in more complex scenarios
  • Use third party plugins to add extra functionalities

Switch to the branch:

git checkout 06-basic-game

Displaying Something

Let's start building a game! First step is to add a new Game variant to the GameState enum, and change to it in the menu instead of just printing something.

We'll just display blocks of color for now, as placeholders. Red is the player, blue is an asteroid.

#![allow(unused)]
fn main() {
extern crate bevy;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
use bevy::prelude::*;

pub fn game_plugin(app: &mut App) {
    app.add_systems(OnEnter(GameState::Game), display_level);
}

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Asteroid;

fn display_level(mut commands: Commands) {
    commands.spawn((
        Sprite::from_color(Color::linear_rgb(1.0, 0.0, 0.0), Vec2::new(50.0, 80.0)),
        Player,
        StateScoped(GameState::Game),
    ));

    commands.spawn((
        Sprite::from_color(Color::linear_rgb(0.0, 0.0, 1.0), Vec2::new(100.0, 100.0)),
        Transform::from_xyz(300.0, -200.0, 0.0),
        Asteroid,
        StateScoped(GameState::Game),
    ));
}
}

Don't forget to add the new game_plugin to the app in the main.rs file.

First Custom Component

A component is a Rust type, a struct or an enum, that implement the Component trait. It is usually derived.

Tag Components

Tag components, or markers, are Zero Sized Types (ZST) used to mark an entity for easier query. Zero Sized Types are types that have only one value possible, and offers optimisations in Rust.

To differentiate between the ground and the player entities, we could use an enum:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
enum Kind {
    Player,
    Asteroid
}
}

And query that component. That would mean the same query would return both the asteroid and the player entities, and we would have to filter based on the value of the component.

By using tag components, the query will return only the entities for the player or the asteroids but not both.

Which is better will depend on your specific use case, the number of entities, how often you need to iterate over, and how you update them.

Required Components

We've spawned two entities with the Sprite component, to display a block of color, but only one with the Transform component, to position it on screen.

Even though it's not specified, the player entity will also have a Transform component, which will be added with the default value.

This is because Transform is a required component of Sprite.

Required components are specified by an attribute when deriving Component, and should implement Default (or specify a constructor in the attribute).

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
#[require(Transform)]
pub struct Sprite {
    /// The sprite's color tint
    pub color: Color,
    // ...
}

#[derive(Component, Default)]
pub struct Transform {
    /// Position of the entity. In 2d, the last value of the `Vec3` is used for z-ordering.
    pub translation: Vec3,
    // ...
}

}

Controlling With Input

We'll control our player with the A and D keys on the keyboard to turn, and W for thrust.

Let's start by handling rotation:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
use std::f32::consts::FRAC_PI_8;
#[derive(Component)]
struct Player;
fn control_player(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player: Query<&mut Transform, With<Player>>,
    time: Res<Time>,
) -> Result {
    let mut player_transform = player.single_mut()?;

    let fixed_rotation_rate = 0.2;
    let rotation_rate = fixed_rotation_rate / (1.0 / (60.0 * time.delta().as_secs_f32()));

    if keyboard_input.pressed(KeyCode::KeyA) {
        player_transform.rotate_z(rotation_rate);
    }
    if keyboard_input.pressed(KeyCode::KeyD) {
        player_transform.rotate_z(-rotation_rate);
    }
    Ok(())
}
}

Don't forget to add the new control_player system to the game_plugin, on FixedUpdate in the GameState::Game state.

Keyboard controls

Bevy exposes a resource ButtonInput<KeyCode> that can be used in a system. KeyCode lists all the keys available on a standard US QWERTY keyboard. They ignore the layout of the user keyboard. This is useful in games to be able to react to the key at the same position no matter the layout.

If you want to handle text input, you should use KeyboardInput instead and use either the logical key (one for each key press) or the actual text (present only when it would add some text, with deadkeys / modifiers applied).

The same ButtonInput interface is used for other kind of button input: ButtonInput<GamepadButton> for gamepads, ButtonInput<MouseButton> for mice.

Modifying transforms

The Transform component controls where an object is in the game world. Modifying it moves the object.

In 2D, the world is Y-up, X-right and Z is out of the screen. This means that to rotate something on screen, it has to be along the Z-axis.

The front of the ship in the image is towards the top, so forward is the Y-axis

Bevy exposes helper methods to manipulate the Transform:

As Bevy doesn't specialize for 2D, Transform has all the needed part for 3D and can be a bit hard to use in 2D.

Time Delta

TODO

Error handling in systems

Bevy systems can return a Result (an alias to Result<(), BevyError>) to be able to use error handling, like ?.

By default, a system that returns an error will cause a panic logging the error. It's possible to change this default behaviour by changing the GLOBAL_ERROR_HANDLER.

Using Assets

Let's improve on those blocks of colors! We'll start by loading a spritesheet for the player

Loading Assets

Loading assets is asynchronous, and returns an Handle to its data. By adding a system to our splash screen, we ensure it happens as early as possible.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Resource)]
struct GameAssets {
    player_ship: Handle<Image>,
}

fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
) {
    commands.insert_resource(GameAssets {
        player_ship: asset_server.load("playerShip1_green.png"),
    });
}
}

Don't forget to add the new load_assets system to the splash_plugin, when entering the GameState::Splash state.

Displaying Those Assets

Now that we have a texture atlas, we can use it to display a sprite for our player instead of a block of red.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Resource)]
struct GameAssets {
    player_ship: Handle<Image>,
}
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Ground;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
fn display_level(mut commands: Commands, game_assets: Res<GameAssets>) {
    commands.spawn((
        Sprite::from_image(game_assets.player_ship.clone()),
        Player,
        StateScoped(GameState::Game),
    ));

    commands.spawn((
        Sprite::from_color(Color::linear_rgb(0.0, 1.0, 0.0), Vec2::new(1000.0, 80.0)),
        Transform::from_xyz(300.0, -200.0, 0.0),
        Ground,
        StateScoped(GameState::Game),
    ));
}
}

Exercises

Don't forget to checkout the branch:

git checkout 06-basic-game

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/05-intro-to-bevy..06-basic-game

Displaying an Asteroid

File meteorBrown_big1.png has a sprite for an asteroid. Use it instead of the blue box.

Tips:

  • Add a new field to the GameAssets resource for the asteroid

Player Sprite Animation

Display engine jets behind the ship when moving forward.

Tips:

  • Load the new sprite in GameAssets. fire07.png is a good sprite for jets
  • Spawn the jets as children sprite of the player entity with the children! macro
    • Make it invisible with the Visibility::Hidden component
    • A good starting position is Vec3::new(0.0, -40.0, 0.0)
  • Toggle sprite visibility when the ship moves forward, when the player presses the W key
    • You can get children of an entity with the Children component
    • Add a new query to the control_player that can modify the Visibility component
    • When W is pressed, query the Visibility component of the first child of the Player entity

Player Acceleration

In space, there's no friction. Pressing W should make the ship accelerate in a direction, and movements should continue after the key is released.

Tips:

  • Store the player velocity in a new component
  • When the W key is pressed, change the velocity
  • In a separate system, move the player according to its current velocity

Basic Physics

Let's add more asteroids, and handle collisions!

More Asteroids

We'll spawn 4 asteroids, at fixed positions for now.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Asteroid;
#[derive(Resource)]
struct GameAssets {
    asteroid: Handle<Image>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Game,
}
fn display_level(mut commands: Commands, game_assets: Res<GameAssets>) {
    // Same player spawning

    // Asteroids spawning
    for (x, y) in [(1., 1.), (-1., 1.), (-1., -1.), (1., -1.)] {
        commands.spawn((
            Sprite::from_image(game_assets.asteroid.clone()),
            Transform::from_xyz(300.0 * x, 200.0 * y, 0.0),
            Asteroid,
            StateScoped(GameState::Game),
        ));
    }
}
}

Collisions

One of the easiest way to test collisions is to consider everything is round, and then check that the distance between two objects is less than the sum of their radii. This is a close enough approximation that works well in our case. Another basic shape that is often used for collision detection is AABB (for Axis-Aligned Bounding Box, so a rectangle).

Let's get the position of the player, and check the distance with every asteroid.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Asteroid;
fn collision(
    asteroids: Query<&Transform, With<Asteroid>>,
    player: Query<&Transform, With<Player>>,
) -> Result {
    let player_radius = 40.0;
    let asteroid_radius = 50.0;
    let player_transform = player.single()?;
    for asteroid_transform in &asteroids {
        let distance = asteroid_transform
            .translation
            .distance(player_transform.translation);
        if distance < (asteroid_radius + player_radius) {
            println!("Collision detected!");
        }
    }

    Ok(())
}
}

Don't forget to add the new collision system to the game_plugin, on Update in the GameState::Game state.

Gizmos

An easy way to debug what is happening on screen are gizmos. They make it possible to draw simple shapes on screen, like circles or rectangles.

We'll draw circles around the different objects, with their radius.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Asteroid;
fn collision(
    asteroids: Query<&Transform, With<Asteroid>>,
    player: Query<&Transform, With<Player>>,
    mut gizmos: Gizmos,
) -> Result {
    let player_radius = 40.0;
    let asteroid_radius = 50.0;
    let player_transform = player.single()?;
    gizmos.circle_2d(
        player_transform.translation.xy(),
        player_radius,
        Color::linear_rgb(1.0, 0.0, 0.0),
    );
    for asteroid_transform in &asteroids {
        gizmos.circle_2d(
            asteroid_transform.translation.xy(),
            asteroid_radius,
            Color::linear_rgb(0.0, 0.0, 1.0),
        );
        let distance = asteroid_transform
            .translation
            .distance(player_transform.translation);
        if distance < (asteroid_radius + player_radius) {
            println!("Collision detected!");
        }
    }

    Ok(())
}
}

It's often useful to add a debug feature to your game, and put things like debug drawing with gizmos behind it!

Exercises

It's very easy to avoid collisions with the asteroids as they don't move... Let's make this game a bit harder!

You can continue from your code, or get the branch with the workshop up till now:

git checkout 06-basic-game-mid

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/06-basic-game..06-basic-game-mid

Moving the Asteroids

Make the asteroids move in random directions, at random speeds.

Tips:

  • Add information about direction and speed to the Asteroid component.
  • Add the rand crate (cargo add rand@0.8) to set them to random values with Rng::gen_range
  • Add a system to update the position of the asteroids based on their direction and speed.

Losing the Game

Let's go back to the menu when colliding with an asteroid.

Tips:

  • Use the ResMut<NextState<GameState>> system parameter to change the current state on collision

Explosion Effect on Collision

It's nicer to see what happened before going back to the menu, let's display an explosion and wait a bit.

Tips:

  • Load the asset for the explosion effect
  • Spawn a sprite at the same Transform as the ship, with a Timer
    • Use the Commands system parameter to spawn the explosion sprite
  • Despawn the ship
  • Add a new system that will tick the timer
  • After the timer is done, despawn it and change state
  • Some systems will return errors now as they try to access the player transform
    • Those are the systems handling player control and collisions with asteroids
    • Those systems shouldn't do anything when there isn't a player
    • Instead of using ? with single / single_mut when querying for it, use let Ok(...) = query.single() else { return Ok(()); };

Actual Physics

Bevy has plenty of third-party plugins.

Let's pick a physics engine that's easy to use with Bevy. There are two options:









We'll use Avian in this workshop, but you could use Rapier and get similar results.

First we'll add a dependency on avian2d to our project:

cargo add avian2d

To finish the setup, we need to add the PhysicsPlugins::default() to our app. And as we're in space, let's remove gravity! This can be done by adding the resource Gravity::ZERO.

Asteroid Movements

Asteroids are the easiest to do! First remove the inertia system, and the fields of the Asteroid component, as that will now be handled by the physics engine.

When spawning an asteroid, we'll need to add the following components:

  • RigidBody
  • Collider
  • LinearVelocity
  • AngularVelocity

And that's it! As a bonus, now asteroids will bounce off each other.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate rand;
use std::f32::consts::TAU;
use bevy::prelude::*;
use avian2d::prelude::*;
use crate::rand::Rng;
#[derive(Component)]
struct Asteroid;
#[derive(Resource)]
struct GameAssets {
    asteroid: Handle<Image>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Game,
}
fn display_level(mut commands: Commands, game_assets: Res<GameAssets>) {
    // Same player spawning

    let mut rng = rand::thread_rng();
    for (x, y) in [(1., 1.), (-1., 1.), (-1., -1.), (1., -1.)] {
        commands.spawn((
            Sprite::from_image(game_assets.asteroid.clone()),
            Transform::from_xyz(300.0 * x, 200.0 * y, 0.0),
            RigidBody::Dynamic,
            Collider::circle(50.0),
            LinearVelocity(Vec2::from_angle(rng.gen_range(0.0..TAU)) * rng.gen_range(10.0..100.0)),
            AngularVelocity(rng.gen_range(-1.5..1.5)),
            Asteroid,
            StateScoped(GameState::Game),
        ));
    }
}
}

Ship Movements

Ship movements are a bit more complicated. As it doesn't have fixed linear and angular velocities, we'll need to change them when reacting to user input.

First, we'll add some components when spawning the ship entity:

  • RigidBody
  • Collider

Another component we'll add is AngularDamping. As the ship is in space, once it's rotating it shouldn't slow down by itself, but that isn't very pleasant to control. Adding damping means that it will stop rotating by itself.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
use bevy::prelude::*;
use avian2d::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Resource)]
struct GameAssets {
    player_ship: Handle<Image>,
    jets: Handle<Image>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Game,
}
fn display_level(mut commands: Commands, game_assets: Res<GameAssets>) {
    commands.spawn((
        Sprite::from_image(game_assets.player_ship.clone()),
        RigidBody::Dynamic,
        Collider::circle(40.0),
        AngularDamping(5.0),
        Player,
        StateScoped(GameState::Game),
        children![(
            Sprite::from_image(game_assets.jets.clone()),
            Transform::from_xyz(0.0, -40.0, -1.0),
            Visibility::Hidden,
        )],
    ));

    // Same asteroids spawning
}
}

And when reacting to user input, we'll modify the AngularVelocity and LinearVelocity components. One thing to keep in mind is to set a maximum LinearVelocity or the ship could accelerate forever and reach an uncontrollable speed.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
use bevy::prelude::*;
use avian2d::prelude::*;
#[derive(Component)]
struct Player;
fn control_player(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player: Query<
        (
            &Transform,
            &mut AngularVelocity,
            &mut LinearVelocity,
            &Children,
        ),
        With<Player>,
    >,
    mut visibility: Query<&mut Visibility>,
) -> Result {
    let Ok((player_transform, mut angular_velocity, mut linear_velocity, children)) = player.single_mut()
    else {
        // No player at the moment, skip control logic
        return Ok(());
    };
    if keyboard_input.pressed(KeyCode::KeyA) {
        angular_velocity.0 += 0.2;
    }
    if keyboard_input.pressed(KeyCode::KeyD) {
        angular_velocity.0 -= 0.2;
    }
    if keyboard_input.pressed(KeyCode::KeyW) {
        let forward = player_transform.local_y();
        linear_velocity.0 += forward.xy() * 2.0;
        linear_velocity.0 = linear_velocity.0.clamp_length_max(300.0);
        *visibility.get_mut(children[0])? = Visibility::Visible;
    } else {
        visibility
            .get_mut(children[0])?
            .set_if_neq(Visibility::Hidden);
    }
    Ok(())
}
}

With that done, we can now remove the move_player system!

Collisions

Last task to move to the physic engine is collision detection

Avian exposes Collisions system parameter that we can use to easily query if something is colliding with an entity.

This is not the idiomatic way to do it. Avian send trigger events that can be caught with observers, which we'll explore later.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
use bevy::prelude::*;
use avian2d::prelude::*;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Explosion(Timer);
#[derive(Resource)]
struct GameAssets {
    explosion: Handle<Image>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Game,
}
fn collision(
    collisions: Collisions,
    player: Query<(&Transform, Entity), With<Player>>,
    mut commands: Commands,
    game_assets: Res<GameAssets>,
) -> Result {
    let Ok((transform, entity)) = player.single() else {
        return Ok(());
    };

    if collisions.collisions_with(entity).next().is_some() {
        commands.spawn((
            Sprite::from_image(game_assets.explosion.clone()),
            (*transform).with_scale(Vec3::splat(0.2)),
            Explosion(Timer::from_seconds(1.0, TimerMode::Once)),
            StateScoped(GameState::Game),
        ));
        commands.entity(entity).despawn();
    }

    Ok(())
}
}

Progress Report

What You've learned

  • Loading sprites and displaying them
  • Defining components
    • With required components to simplify adding related components
    • And using Zero Sized Types as tag components to filter entities in queries
  • Handling user input
  • Writing more complex queries, and updating components
  • Error handling in systems
  • Third Party Plugins

Level Loading

Learn how to load and manage levels in your game. This involves:

  • Creating a custom asset format
  • Implementing an asset loader
  • Accessing the asset at runtime
  • Starting the level based on its file

Switch to the branch:

git checkout 07-level-loading

Custom Asset Format

Level Format

We'll load the level information from a basic text file. The information we want from it are:

  • Width and height of the level
  • Number of asteroids to spawn
  • Number of lives of the player

Asset Type

To match the basic level format, we'll use a basic struct that will hold four u32s. The struct must derive the Asset trait.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Asset, TypePath)]
pub struct Level {
    pub width: u32,
    pub height: u32,
    pub asteroids: u32,
    pub lives: u32,
}
}

Asset Loader

Let's add thiserror as a dependency, this will help us when declaring the kind of errors that can happen when loading our file.

cargo add thiserror

To load this format, we'll read the file character by character, then choose the right tile depending on the character. Bevy expects custom asset loader to implement the trait AssetLoader.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate thiserror;
use bevy::{asset::{io::Reader, AssetLoader, AsyncReadExt, LoadContext}, prelude::*};
use thiserror::Error;
#[derive(Asset, TypePath)]
struct Level {width: u32, height: u32, asteroids: u32, lives: u32}
#[derive(Default)]
struct LevelLoader;

#[derive(Debug, Error)]
enum LevelLoaderError {
    #[error("Could not load asset: {0}")]
    Io(#[from] std::io::Error),
    #[error("Error in file format")]
    FormatError,
}

impl AssetLoader for LevelLoader {
    type Asset = Level;
    type Settings = ();
    type Error = LevelLoaderError;
    async fn load(
        &self,
        reader: &mut dyn Reader,
        _settings: &(),
        _load_context: &mut LoadContext<'_>,
    ) -> Result<Self::Asset, Self::Error> {
        let mut buf = String::new();
        reader.read_to_string(&mut buf).await?;

        let mut lines = buf.lines();
        Ok(Level {
            width: lines
                .next()
                .and_then(|s| s.parse().ok())
                .ok_or(LevelLoaderError::FormatError)?,
            height: lines
                .next()
                .and_then(|s| s.parse().ok())
                .ok_or(LevelLoaderError::FormatError)?,
            asteroids: lines
                .next()
                .and_then(|s| s.parse().ok())
                .ok_or(LevelLoaderError::FormatError)?,
            lives: lines
                .next()
                .and_then(|s| s.parse().ok())
                .ok_or(LevelLoaderError::FormatError)?,
        })
    }

    fn extensions(&self) -> &[&str] {
        &["bw"]
    }
}
}

Loading the Level

Custom asset formats and loaders must be initiated in the application with App::init_asset and App::init_asset_loader. We can wrap that in a plugin.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate thiserror;
use bevy::{asset::{io::Reader, AssetLoader, AsyncReadExt, LoadContext}, prelude::*};
use thiserror::Error;
#[derive(Asset, TypePath)]
struct Level {width: u32, height: u32, asteroids: u32, lives: u32}
#[derive(Default)]
struct LevelLoader;
#[derive(Debug, Error)]
enum LevelLoaderError {}
impl AssetLoader for LevelLoader {
    type Asset = Level;
    type Settings = ();
    type Error = LevelLoaderError;
    async fn load(&self, reader: &mut dyn Reader, _settings: &(), _load_context: &mut LoadContext<'_>) -> Result<Self::Asset, Self::Error> { unimplemented!() }
    fn extensions(&self) -> &[&str] { &["bw"] }
}
pub fn level_loader_plugin(app: &mut App) {
    app.init_asset::<Level>().init_asset_loader::<LevelLoader>();
}
}

Don't forget to add the new level_loader_plugin to the app in the main.rs file.

Now we can load the asset file like the sprites we're already using, and keeping the handle to the loaded level in a resource.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{asset::{io::Reader, AssetLoader, AsyncReadExt, LoadContext}, prelude::*};
#[derive(Asset, TypePath)]
struct Level {width: u32, height: u32, asteroids: u32, lives: u32}
#[derive(Resource)]
pub struct LoadedLevel {
    pub level: Handle<Level>,
}

fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    // ...
) {
    commands.insert_resource(LoadedLevel {
        level: asset_server.load("level.bw"),
    });
    // ...
}
}

Displaying the Level

Loading an asset is an asynchronous process. As it involves file or network access, it doesn't happen immediately. This is why the asset server is returning an Handle instead of the data.

Accessing the data from the Assets<T> resource returns an Option<T> as the data may not be present yet. In our case, we're using the 2 second delay of the splash screen to be sure that assets are done loading, so we can unwrap() the Option.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Asset, TypePath)]
struct Level {width: u32, height: u32, asteroids: u32, lives: u32}
#[derive(Resource)]
struct GameAssets {
}
#[derive(Resource)]
pub struct LoadedLevel { pub level: Handle<Level> }
#[derive(Component)]
struct Asteroid;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
fn display_level(
    mut commands: Commands,
    game_assets: Res<GameAssets>,
    loaded_level: Res<LoadedLevel>,
    levels: Res<Assets<Level>>,
) {
    let level = levels.get(&loaded_level.level).unwrap();

    // do something with the level
}
}

Waiting for some time is not a good general solution to ensure assets are loaded: the actual delay will depend on the number of asset, the disk and CPU of the player.

There are different ways to go around that: you can poll assets in a system to check that they are available, you can wait for asset events or you can use a guard when loading assets.

A third party plugin that handles asset loading through states is bevy_asset_loader.

Exercises

Don't forget to checkout the branch:

git checkout 07-level-loading

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/06-basic-game..07-level-loading

Spawn the correct number of asteroids

Spawn all the asteroids in the level

Tips:

  • Find a random position in the arena
  • Avoid spawning an asteroid on top of the player
    • Find a random position in the arena
    • Ensure the distance from the center is more than the radius of the player plus a safe margin

Player can have multiple lives

Don't return to the menu on the first collision. Instead, do it when the player doesn't have any live remaining

Tips:

  • Add the number of lives as a resource at the start of the game
  • On collision, decrement the number of lives
  • After a collision, wait for a few seconds before respawning the player
  • You can move the code spawning the player to a separate function to be able to call it either at game start or on respawn
  • Decide where to respawn:
    • At the game starting point
    • At the player last position
    • At a random position in the arena
    • The respawn point shouldn't have an asteroid or the player would die again immediately
  • If the number of lives is 0, game over

Display information about the level

Let's display some information about the current game:

  • Number of asteroids remaining
  • Lives remaining
  • Time spent in the level

Tips:

  • Start a new plugin hud (for heads up display)
  • Add a system when entering the Game state that will display some text and start a StopWatch
    • Add a StopWatch as a resource
    • Spawn an entity with a Text component
    • Spawn children with a TextSpan component. Text spans make it easy to change the style of the text, or to target a specific part for editing
  • Add a system that will update the text
    • Target the entity with the Text component
    • Use the TextWriter system parameter to update the text
    • And tick the stopwatch

Progress Report

What You've learned

  • Loading a custom asset file
    • Creating a custom asset by defining a struct deriving the Asset trait
    • And implementing the AssetLoader trait to load a file into this struct
  • Getting an asset

Going Further

Assets can be hot-reloaded. This can be useful during development, to be able to quickly change the level without recompiling and restarting the game.

  • It needs to enable a feature on Bevy: file_watcher
  • Check if the asset changed, then despawn the level and respawn it from the updated file

Player Actions

Until now, we have one system that reacts to all user actions. This doesn't scale well and we will end up with a very long and very complex system.

In this section, you will:

  • Learn how to use an input manager
  • Learn about triggers and observers

These concepts are essential for creating interactive game elements that respond to player actions.

Switch to the branch:

git checkout 08-player-actions

Action Mapper

Goal of the game is to destroy all the asteroids.

Let's give the player a way to do that! But first let's refactor how we handle the player's actions so that it's easier to extend.

Using an Input Manager

First step is to add the new dependency to our project

cargo add bevy_enhanced_input

And, as is customary with Bevy plugins, we need to add the plugin to our application. In our main function, we can add it with the physics plugin:

extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::{PhysicsPlugins, prelude::Gravity};
use bevy::prelude::*;
use bevy_enhanced_input::EnhancedInputPlugin;
fn main() {
    App::new()
        // ...
        .add_plugins((PhysicsPlugins::default(), EnhancedInputPlugin))
        // ...
;
}

In our game_plugin, we will now remove the control_player system.

First step to start using input mapping is to enable an input context. This is useful in more complex games where control schemes change depending on the current game mode. Here we will have only one input context to control the ship.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate bevy_enhanced_input;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
fn display_level() {}
fn collision() {}
fn tick_explosion() {}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState {
    #[default]
    Splash,
    StartMenu,
    Game,
}
#[derive(InputContext)]
struct ShipController;

pub fn game_plugin(app: &mut App) {
    app.add_input_context::<ShipController>()
        .add_systems(OnEnter(GameState::Game), display_level)
        .add_systems(
            Update,
            (collision, tick_explosion).run_if(in_state(GameState::Game)),
        );
}
}

First Action: Ship Rotation

We can now declare our actions! Let's start with how to rotate the ship:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::{PhysicsPlugins, prelude::Gravity};
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = f32)]
struct Rotate;
}

This is an InputAction that will return a f32, whose sign will give us the direction in which the ship will rotate.

For this action to be triggered, we need to add an a Actions component to our ship. In the function spawn_player, we'll create it and add it to the other components:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::{PhysicsPlugins, prelude::Gravity};
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = f32)]
struct Rotate;
struct GameAssets;
#[derive(InputContext)]
struct ShipController;
fn spawn_player(commands: &mut Commands, game_assets: &GameAssets) {
    let mut actions = Actions::<ShipController>::default();

    actions.bind::<Rotate>().to(Bidirectional {
        positive: KeyCode::KeyA,
        negative: KeyCode::KeyD,
    });

    commands
        .spawn((
            // The other components
            actions,
        ));
}
}

Observers

To react to the input action, we use a system that takes a Trigger as a system parameter, plus any other parameter needed. Those systems are known as Observers.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = f32)]
struct Rotate;
fn rotate(
    trigger: Trigger<Fired<Rotate>>,
    mut player: Query<&mut AngularVelocity>,
    time: Res<Time>,
) -> Result {
    let fixed_rate = 0.2;
    let delta = time.delta().as_secs_f32();
    let rate = fixed_rate / (1.0 / (60.0 * delta));
    let mut angular_velocity = player.get_mut(trigger.target())?;
    angular_velocity.0 += trigger.value.signum() * rate;

    Ok(())
}
}

The Trigger<Fired<Rotate>> system parameter means that this system will run every time a Fired<Rotate> event is triggered. bevy_enhanced_input sends different events for an action, for example Started<Rotate>, Fired<Rotate> and Completed<Rotate>. In this case we want to react as long as the Rotate action happens.

We will attach this observer to our ship entity:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = f32)]
struct Rotate;
struct GameAssets;
fn rotate(trigger: Trigger<Fired<Rotate>>) -> Result {Ok(())}
fn spawn_player(commands: &mut Commands, game_assets: &GameAssets) {
    commands
        .spawn((
            // all the components
        ))
        .observe(rotate);
}
}

Second action: Thrust

We need another action for thrust, this time its output should be just a boolean: is there thrust or not.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct Thrust;
}

We need to react when thrust is fired, adding linear velocity to the ship, and once it's finished, to remove the jets. Let's create our two systems for that:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct Thrust;
fn thrust(
    trigger: Trigger<Fired<Thrust>>,
    mut player: Query<(&Transform, &mut LinearVelocity, &Children)>,
    mut visibility: Query<&mut Visibility>,
) -> Result {
    let (transform, mut linear_velocity, children) = player.get_mut(trigger.target())?;
    linear_velocity.0 += transform.local_y().xy() * 2.0;
    linear_velocity.0 = linear_velocity.0.clamp_length_max(200.0);
    visibility
        .get_mut(children[0])?
        .set_if_neq(Visibility::Visible);
    Ok(())
}

fn thrust_stop(
    trigger: Trigger<Completed<Thrust>>,
    player: Query<&Children>,
    mut visibility: Query<&mut Visibility>,
) -> Result {
    let children = player.get(trigger.target())?;

    visibility
        .get_mut(children[0])?
        .set_if_neq(Visibility::Hidden);

    Ok(())
}
}

And we're ready to define when this action will be executed, and to add the observers to our ship:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = f32)]
struct Rotate;
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct Thrust;
fn rotate(trigger: Trigger<Fired<Thrust>>) -> Result {Ok(())}
fn thrust(trigger: Trigger<Fired<Thrust>>) -> Result {Ok(())}
fn thrust_stop(trigger: Trigger<Completed<Thrust>>) -> Result {Ok(())}
struct GameAssets;
#[derive(InputContext)]
struct ShipController;
fn spawn_player(commands: &mut Commands, game_assets: &GameAssets) {
    let mut actions = Actions::<ShipController>::default();

    actions.bind::<Rotate>().to(Bidirectional {
        positive: KeyCode::KeyA,
        negative: KeyCode::KeyD,
    });
    actions.bind::<Thrust>().to(KeyCode::KeyW);

    commands
        .spawn((
            // the other components
            actions,
        ))
        .observe(rotate)
        .observe(thrust)
        .observe(thrust_stop);
}
}

Triggers

All the events we are using are triggered by bevy_enhanced_input, so we're just reacting to them. It's also possible to trigger your own events, this can be done through commands.

Scope of Observers

They can be global with Commands::trigger and App::add_observer, or specific to an entity with EntityCommands::trigger and EntityCommands::observe.

Exercises

Don't forget to checkout the branch:

git checkout 08-player-actions

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/07-level-loading..08-player-actions

Idiomatic Ship Collision Detection

Now we can remove the collision system, and check for asteroid collisions on our ship with an observer!

Tips:

  • Remove the existing collision system from the Update schedule
  • Add the CollisionEventsEnabled component to the ship entity
  • Change the previous collision system to be an observer that will trigger on Trigger<OnCollisionStart>

Can Destroy Asteroids

To destroy asteroid, we need to be able to fire lasers!

Tips:

  • New action to fire lasers
  • New sprite for lasers
  • Spawn a laser when the action is fired
    • With a Sprite
    • With a RigidBody
    • With a Collider
    • With a LinearVelocity
    • With CollisionEventsEnabled
  • Despawn lasers after a certain time
  • Observe collisions between asteroids and lasers
  • Despawn asteroid and laser when they collide

Detect when all Asteroids are Destroyed

Switch to a win screen when all asteroids are destroyed

Tips:

  • Add a new state GameState::Won
  • Copy the menu and change the text and all the states used
  • Add a system with a query on asteroids
  • When there are no asteroids anymore, change state

Exercises

It's possible for the player to go off-screen, which makes the game harder to control. Let's do some camera work!

Make the camera follow the player

Translate the camera along with the player ship.

Tips:

  • Get the player ship Transform
  • Move the camera Transform to the same translation

Closest Asteroid Indicator

Let's make it easier to find the last few asteroids!

Find the closest asteroid, and display an indicator of its direction.

Tips:

  • Iterate over all asteroids, and find the one closest to the player ship
  • Find its direction
  • Display an indicator (Gizmos have an helpful arrow_2d function)

Make it easier to finish

Once you're done to the last few asteroids, it can be a bit boring to hunt the last few ones. Let's make it even easier for the player.

If the closest asteroid is farther away than some distance, send the asteroid towards the player.

Tips:

  • Check the distance between the ship and the closest asteroid
  • If it's too far away, send the asteroid towards the ship

Progress Report

What You've Learned

  • How to implement reactivity
    • By using Trigger and observers
    • Or with optional components to change how an existing query behaves
  • How to use an action manager
    • Declaring actions
    • Binding them to inputs
    • And triggering them on entities

Going Further

  • Using Component hooks to react when a component is added/changed or removed.

Sound Effects

Learn how to integrate sound effects into your game to enhance the player's experience. Sound effects can significantly impact the atmosphere and immersion of your game, making it more engaging and enjoyable.

By the end of this section, you will be able to:

  • Load and manage sound assets
  • Play sound effects in response to game events
  • Control sound properties such as volume and pitch

Switch to the branch:

git checkout 09-sound-effects

Firing Lasers

Load an Audio Asset

We'll create a new resource to hold the handles to audio assets, and load it in the load_assets system.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Resource)]
pub struct AudioAssets {
    pub laser: Handle<AudioSource>,
}

fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    // ...
) {
    commands.insert_resource(AudioAssets {
        laser: asset_server.load("laser.wav"),
    });
    // ...
}

}

The build-in type for audio is AudioSource.

Trigger an Event to Play Audio

We'll trigger an event when we want to play audio. For now, that is when the player is starting to jump. To avoid triggering to many events, we should make sure that the player was not already jumping.

We'll start by declaring an event type:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
pub enum AudioStart {
    Laser,
}
}

To send an event, we can use the EventWriter system parameter:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate bevy_enhanced_input;
use bevy::prelude::*;
use bevy_enhanced_input::prelude::*;
use bevy::prelude::*;
#[derive(Event)]
pub enum AudioStart { Laser }
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct FireLaser;
fn fire_laser(
    trigger: Trigger<Fired<FireLaser>>,
    mut audio: EventWriter<AudioStart>
    // Other system parameters
) {
    let target = trigger.target();
    // Other actions to fire a laser

    audio.write(AudioStart::Laser);
}
}

Play Audio when Receiving the Event

To receive an event, we must use the EventReader system parameter, and by calling EventReader::read we can iterate over events.

To play audio, we must spawn an entity with the AudioPlayer component that will contain an Handle to the AudioSource asset.

By default, audio entities remain present once the audio is done playing. You can change this behaviour with the component PlaybackSettings::DESPAWN.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
enum AudioStart {Laser}
#[derive(Resource)]
struct AudioAssets { laser: Handle<AudioSource> }
fn play_audio(
    mut commands: Commands,
    mut audio_triggers: EventReader<AudioStart>,
    sound_assets: Res<AudioAssets>,
) {
    for trigger in audio_triggers.read() {
        match trigger {
            AudioStart::Laser => {
                commands.spawn((
                    AudioPlayer::<AudioSource>(sound_assets.laser.clone()),
                    PlaybackSettings::DESPAWN,
                ));
            }
        }
    }
}
}

We'll start a new plugin for all the audio related actions. Unlike events used with triggers and observers, events used with EventWriter and EventReader must be registered in the application with App::add_event. The plugin will register the event and add the system.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
enum AudioStart {Lasers}
fn play_audio() {}
pub fn audio_plugin(app: &mut App) {
    app.add_event::<AudioStart>()
        .add_systems(Update, play_audio);
}
}

Exercises

Don't forget to checkout the branch:

git checkout 09-sound-effects

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/08-player-actions..09-sound-effects

Explosions

Add sound for asteroid and ship explosions.

Tips:

Other Events

Add sound for game start, winning and losing.

Tips:

Background Music

Add a background music

Tips:

Progress Report

What You've learned

  • Playing a sound reacting to an action by the user
  • How events work
  • Playing a background music

Going Further

This workshop uses wav files as they are easier to generate from tools. In a released game, I would recommend another format, mostly ogg, as it has better compression.

Visual Effects

Enhance your game's visual appeal with effects. This is often achieved using shaders, which are programs that run on the GPU. The preferred language for writing them in Bevy is the WebGPU Shading Language, which is translated as needed by the platform on which the application is running.

Bevy provides several abstractions for rendering:

  • Directly using images, colors, or texture atlases, which is what we've been doing so far. The shaders are built into Bevy, optimized for performance at the expense of customization.
  • Custom materials, which we'll explore in this section. For 2D, you'll need to implement the Material2d trait.
  • Lower-level abstractions, offering complete control over the entire rendering pipeline. This won't be covered in this workshop.

Switch to the branch:

git checkout 10-visual-effects

Background

We'll build a first shader for the background that will displayed some stars.

Let's create a new plugin for it, we'll call it starfield!

We want our starfield to be different each time it's loaded, so we will seed it with random values. We also want some kind of parallax effect, where bigger stars in the foreground appear closer than smaller stars in the background. To achieve that we will also pass the current player position to the shader.

Custom GPU type

First step is to declare the data we'll send to the GPU:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{prelude::*, render::render_resource::{AsBindGroup, ShaderRef, ShaderType}};
#[derive(Asset, TypePath, AsBindGroup, ShaderType, Debug, Clone)]
#[uniform(0, StarfieldMaterial)]
pub struct StarfieldMaterial {
    position: Vec2,
    seeds: Vec2,
}

impl<'a> From<&'a StarfieldMaterial> for StarfieldMaterial {
    fn from(material: &'a StarfieldMaterial) -> Self {
        material.clone()
    }
}
}

By deriving the AsBindGroup trait and annotating with uniform, Bevy will be able to know how to transform the data from Rust type to what is expected by the GPU.

You can add the uniform annotation on fields, and for most common types Bevy knows out of the box how to convert them in a format understanble by the GPU.

If you want to make available a type you defined, you'll need to derive the ShaderType trait on it. Then by using that type in the uniform, Bevy will know how to send data to the GPU.

Here, the data has the same types on CPU or GPU, so we're able to use the same type for both representation. A more complete version would be:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{prelude::*, render::render_resource::{AsBindGroup, ShaderRef, ShaderType}};
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
#[uniform(0, StarfieldUniform)]
pub struct StarfieldMaterial {
    position: Vec2,
    seeds: Vec2,
}

#[derive(ShaderType, Debug, Clone)]
pub struct StarfieldUniform {
    position: Vec2,
    seeds: Vec2,
}

impl<'a> From<&'a StarfieldMaterial> for StarfieldUniform {
    fn from(material: &'a StarfieldMaterial) -> Self {
        StarfieldUniform {
            position: material.position,
            seeds: material.seeds,
        }
    }
}
}

StarfieldMaterial will be the material used on the CPU, and StarfieldUniform the data used on the GPU.

In this case, our material is made of two Vec2, and will work fine on all platforms. WebGL2 in particular need uniforms to be 16 bytes aligned or will crash.

The two strategies to solve that are padding and packing. Padding is using bigger types than necessary and wasting memory, packing is grouping fields that have separate meaning in a single type.

Custom Material

Next is to define the shader that will be used to render the data. This is done by implementing the Material2d trait:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef},
    sprite::{AlphaMode2d, Material2d, Material2dPlugin},
};
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct StarfieldMaterial {}
impl Material2d for StarfieldMaterial {
    fn fragment_shader() -> ShaderRef {
        "starfield.wgsl".into()
    }
}
}

The trait has more customisation than used here, and use sane defaults. By just using a string for the fragment shader, Bevy will load the file specified from the asset folder.

This is a basic shader that will display the sprite selected by the index from a sprite sheet:

#import bevy_sprite::{
    mesh2d_vertex_output::VertexOutput,
    mesh2d_view_bindings::globals,
}

struct Material {
    coords: vec2<f32>,
    seeds: vec2<f32>,
}

@group(2) @binding(0)
var<uniform> material: Material;

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    var result = vec3<f32>(0.0, 0.0, 0.0);

    // ...

    return vec4<f32>(result, 1.0);
}

Bevy has some extensions to WGSL to allow imports and expose some helpful features.

Variables with the @group(2) will match the bind group declared on Rust side.

Using the Material

Our new material must be added to Bevy before it can be used. This can be done in a plugin:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef, ShaderType},
    sprite::{Material2d, Material2dPlugin},
};
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct StarfieldMaterial {}
impl Material2d for StarfieldMaterial {}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game };
pub fn setup() {}
pub fn update_starfield() {}
pub fn starfield_plugin(app: &mut bevy::prelude::App) {
    app.add_plugins(Material2dPlugin::<StarfieldMaterial>::default())
        .add_systems(OnEnter(GameState::Game), setup)
        .add_systems(
            PostUpdate,
            update_starfield.run_if(in_state(GameState::Game)),
        );
}
}

We're adding two systems:

  • setup which will spawn the material on the background
  • update_starfield which will send the updated player position to the material

setup looks like this:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate rand;
use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef, ShaderType},
    sprite::{Material2d, Material2dPlugin},
};
use rand::Rng;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct StarfieldMaterial { position: Vec2, seeds: Vec2 }
impl Material2d for StarfieldMaterial {}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game };
fn setup(
    mut commands: Commands,
    mut meshes: ResMut<Assets<Mesh>>,
    mut materials: ResMut<Assets<StarfieldMaterial>>,
    windows: Query<&Window>,
) {
    let window = windows.single().unwrap();
    let size = window.width().max(window.height());

    commands.spawn((
        // Apply the material to a square
        Mesh2d(meshes.add(Rectangle::default())),
        MeshMaterial2d(materials.add(StarfieldMaterial {
            // At start, player position is (0.0, 0.0)
            position: Vec2::ZERO,
            // Seed the material with random values
            seeds: Vec2::new(
                rand::thread_rng().gen_range(0.0..1000.0),
                rand::thread_rng().gen_range(0.0..1000.0),
            ),
        })),
        // Scale up the material so that it covers the whole screen
        Transform::from_scale(Vec3::new(size, size, 1.0)),
        StateScoped(GameState::Game),
    ));
}
}

update_starfield will update the position field in our material with the current player position, and will also change the material scale in case the window is resized.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate rand;
use bevy::{
    prelude::*,
    render::render_resource::{AsBindGroup, ShaderRef, ShaderType},
    sprite::{Material2d, Material2dPlugin},
    window::WindowResized,
};
use rand::Rng;
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct StarfieldMaterial { position: Vec2, seeds: Vec2 }
impl Material2d for StarfieldMaterial {}
fn update_starfield(
    mut starfield: Query<(&mut Transform, &MeshMaterial2d<StarfieldMaterial>), Without<Camera2d>>,
    camera: Query<Ref<Transform>, With<Camera2d>>,
    mut materials: ResMut<Assets<StarfieldMaterial>>,
    mut resized: EventReader<WindowResized>,
) {
    // As the camera follows the player, take the camera transform
    let camera_transform = camera.single().unwrap();
    if camera_transform.is_changed() {
        let (mut starfield_transform, material) = starfield.single_mut().unwrap();

        // Change the starfield transform so that it stays in sync with the camera
        starfield_transform.translation = camera_transform.translation.with_z(-2.0);

        // Update the position in the material
        let material = materials.get_mut(&material.0).unwrap();
        material.position = camera_transform.translation.xy();
    }

    if let Some(resized) = resized.read().last() {
        let (mut starfield_transform, _) = starfield.single_mut().unwrap();

        // Window size changed, update the size of the mesh showing the material
        starfield_transform.scale.x = resized.width.max(resized.height);
        starfield_transform.scale.y = resized.width.max(resized.height);
    }
}
}

Let's Put Some Stars in the Sky!

Right now our shader is just displaying the emptiness of space... everything is black.

To have "stars", we'll want to display some specks of white at some points. There are plenty of examples we can take inspiration on Shadertoy.

We'll first define two "random" functions. They are actually deterministic, which helps with keeping the stars in place.

// Returns a single f32 for a position
fn rand(p: vec2<f32>) -> f32 {
    return fract(sin(dot(p, vec2<f32>(54.90898, 18.233))) * 4337.5453);
}

// Returns two f32 for a position
fn rand2(p: vec2<f32>) -> vec2<f32> {
    let p2 = vec2<f32>(dot(p, vec2<f32>(12.9898, 78.233)), dot(p, vec2<f32>(26.65125, 83.054543)));
    return fract(sin(p2) * 43758.5453);
}

Using those two functions, we can create a starfield! This is the most complicated part of the shader, and not really linked to Bevy. It takes the density of stars we want, their size and their brightness, and for each point on screen will return if it's in a star or not.

fn stars(position: vec2<f32>, density: f32, size: f32, brightness: f32) -> f32 {
    let n = position * density;
    let f = floor(n);

    var d = 1.0e10;
    for (var i = -1; i <= 1; i = i + 1) {
        for (var j = -1; j <= 1; j = j + 1) {
            var g = f + vec2<f32>(f32(i), f32(j));
            g = n - g - rand2(g % density) + rand(g);
            g = g / (density * size);
            d = min(d, dot(g, g));
        }
    }

    return brightness * (smoothstep(.95, 1., (1. - sqrt(d))));
}

We can now call this function in our fragment shader to be able to draw stars!

@fragment
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
    var result = vec3<f32>(0.0, 0.0, 0.0);

    result = result + stars(in.uv, 30.0, 0.025, 0.5);

    return vec4<f32>(result, 1.0);
}

It's a start, but very bland for now: stars are just points on screen that don't move.

To create a parallax effect, we want different layers of stars that don't move at the same speed.

    result = result + stars(in.uv - coords / (1000.0 * 1.2), 3.0, 0.025, 2.0);
    result = result + stars(in.uv - coords / (1000.0 * 1.4), 10.0, 0.018, 1.0);
    result = result + stars(in.uv - coords / (1000.0 * 2.0), 30.0, 0.015, 0.5);

This will create three layers of stars, with different sizes, and not moving at the same speed relative to the player.

✍️ Exercises

Don't forget to checkout the branch:

git checkout 10-visual-effects

Let's review what was changed: https://github.com/vleue/bevy_workshop-rustweek-2025/compare/09-sound-effects..10-visual-effects

Twinkle Twinkle Little Star

Stars twinkle, they don't stay at the same intensity all the time. Let's try to get that in our shader!

Tips:

  • globals.time is available in the shader to do things that changes according to time
  • Make a random number for star intensity that takes the time and the on screen position (so that all stars don't have the same intensity at the same time)
  • multiply that value for each star layer

More Star Variations

Make our star more diverse! Right now every run they have the same positions. Use our seeds to have different stars every time. And why not add some colors?

Tips:

  • Use material.seeds with each star layer
  • Multiply them by different colors

New Ship

Let's add a shader displaying an effect when the a new ship is spawned.

Tips:

  • Use the time the ship was spawned in the material
  • Try to find a cool effect on https://www.shadertoy.com and port it
  • If you want to modify the ship image, you'll need to create a new material and pass the image as a uniform
  • If you want to display something hover the ship, you can make a simple material and display it with a higher z value

Bloom

Bloom is another way to improve how our game looks. It's very easy to enable it, and we can follow Bevy example for that: 2D Bloom.

Enable bloom

When spawning our Camera2d in the display_title system, we'll need to add a few components for bloom:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{core_pipeline::bloom::Bloom, prelude::*};
fn display_title(mut commands: Commands) {
    commands.spawn((
        Camera2d,
        Camera {
            hdr: true,
            ..default()
        },
        Bloom::default(),
    ));

    // ...
}
}

And that's it! Bloom is enabled.

But by itself that isn't enough to see a change on screen, for that we need to do something to our colors.

Blooming Laser!

A good candidate for bloom is our laser. To do that, when spawning the Sprite component with the handle to the image, we'll also provide a color. To have a bloom effect, the color should bigger value on some channels than 1.0. As our laser is red, let's try Color::srgb(5.0, 1.0, 1.0) which should emit a red light.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Resource)]
struct GameAssets { laser: Handle<Image> }
fn system(mut commands: Commands, game_assets: Res<GameAssets>) {
commands
    .spawn((
        Sprite {
            image: game_assets.laser.clone(),
            color: Color::srgb(5.0, 1.0, 1.0),
            ..default()
        },
        // ...
    ));
}
}

🎁 Particles

Let's add some particles! They are a good effect to help make a game look nicer, and can be easy to add.

Bevy doesn't have first party support for particles, but there are at least two third party plugins that provide that:

bevy_hanabi uses the GPU through compute shaders, while bevy_enoki does it all on the CPU. In our case, as we want our game to work on Wasm in WebGL2 where compute shaders are not available, we'll use bevy_enoki.

bevy_enoki Setup

Let's add the plugin to our project:

cargo add bevy_enoki

And the plugin to our app:

extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
extern crate bevy_enoki;
use avian2d::{PhysicsPlugins, prelude::Gravity};
use bevy::prelude::*;
use bevy_enoki::EnokiPlugin;
use bevy_enhanced_input::EnhancedInputPlugin;
fn main() {
    App::new()
        // ...
       .add_plugins(DefaultPlugins)
        .add_plugins((PhysicsPlugins::default(), EnhancedInputPlugin, EnokiPlugin))
        // ...
;
}

Creating a Particle Effect for our Ship Jet

bevy_enoki particle effects are declared through an Particle2dEffect asset. The easiest way to do that is through a ron configuration file.

In the file assets/jet.particle.ron, add the following content:

(
    spawn_rate: 0.1,
    spawn_amount: 1,
    emission_shape: Point,
    lifetime: (1.0, 0.0),
    linear_speed: Some((100, 0.1)),
    direction: Some(((0, -1), 0.1)),
    scale: Some((3., 1.)),
    color: Some((red: 3.0, green: 3.0, blue: 0.0, alpha: 1.0)),
)

You don't need to define all the fields, only the one that you want to set with e different value than the default one.

If you look at the color we defined, it will be a yellow that will have a bloom effect.

We can now load that file in our GameAssets struct, in a field jet of type Handle<Particle2dEffect>.

We'll load it as a "sibling" to our jet Sprite. Like the jet which is set to hidden when no thrust is applied, the particle effect will be set as inactive:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate bevy_enoki;
use bevy::prelude::*;
use bevy_enoki::prelude::*;
#[derive(Resource)]
struct GameAssets{ player_ship: Handle<Image>, jet_particles: Handle<Particle2dEffect> }
fn spawn_player(commands: &mut Commands, game_assets: &GameAssets) {
    // Actions setup

    commands
        .spawn((
            Sprite::from_image(game_assets.player_ship.clone()),
            // Rest of the components of the ship
            children![
                (
                    // Components for the jet sprite
                ),
                (
                    ParticleSpawner::default(),
                    ParticleSpawnerState {
                        active: false,
                        ..default()
                    },
                    ParticleEffectHandle(game_assets.jet_particles.clone()),
                    Transform::from_xyz(0.0, -40.0, 0.0),
                )
            ],
        ))
    // ...
;
}
}

And we'll need to enable the particle effect in the thrust system, and disable it in the thrust_stop system.

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate avian2d;
extern crate bevy_enhanced_input;
extern crate bevy_enoki;
use avian2d::prelude::*;
use bevy::prelude::*;
use bevy_enoki::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct Thrust;
fn thrust(
    trigger: Trigger<Fired<Thrust>>,
    mut player: Query<(&Transform, &mut LinearVelocity, &Children)>,
    mut visibility: Query<&mut Visibility>,
    mut particle_state: Query<&mut ParticleSpawnerState>,
) -> Result {
    let (transform, mut linear_velocity, children) = player.get_mut(trigger.target())?;
    linear_velocity.0 += transform.local_y().xy() * 2.0;
    linear_velocity.0 = linear_velocity.0.clamp_length_max(300.0);

    // Make jet sprite visible
    visibility
        .get_mut(children[0])?
        .set_if_neq(Visibility::Visible);

    // Make jet particles active
    particle_state
        .get_mut(children[1])?
        .map_unchanged(|s| &mut s.active)
        .set_if_neq(true);

    Ok(())
}
}

And similarly in the thrust_stop system:

#![allow(unused)]
fn main() {
extern crate bevy;
extern crate bevy_enhanced_input;
extern crate bevy_enoki;
use bevy::prelude::*;
use bevy_enoki::prelude::*;
use bevy_enhanced_input::prelude::*;
#[derive(Debug, InputAction)]
#[input_action(output = bool)]
struct Thrust;
fn thrust_stop(
    trigger: Trigger<Completed<Thrust>>,
    player: Query<&Children>,
    mut visibility: Query<&mut Visibility>,
    mut particle_state: Query<&mut ParticleSpawnerState>,
) -> Result {
    let Ok(children) = player.get(trigger.target()) else {
        return Ok(());
    };

    // Make the jet sprite hidden
    visibility
        .get_mut(children[0])?
        .set_if_neq(Visibility::Hidden);

    // Make the jet particle inactive
    particle_state
        .get_mut(children[1])?
        .map_unchanged(|s| &mut s.active)
        .set_if_neq(false);

    Ok(())
}
}

Progress Report

What You've learned

  • Defining a custom material
    • With the AsBindGroup derive and its attributes to handle data transfer to the GPU
    • Implementing the Material2d trait to define the shader
    • And some basic WGSL
  • And using that material
  • Enabling bloom
    • How to enable bloom on the camera
    • How to have a color display a bloom effect by going higher than 1.0 on one of the color channel
  • And getting a little more effects with particles
    • Using a third party plugin, bevy_enoki

Going Further

Shaders and rendering is a very big domain. You can start by reading the Book of Shaders and the Learn WGPU tutorial.

Platforms Support

Native

Crossbuilding?

wasm

  • Build steps
    • wasm-bindgen-cli
  • WebGL2 or WebGPU
  • HTML template
    • with audio trick
  • Assets should be served as HTTP
  • bevy CLI

SteamDeck

  • Fullscreen

Gamepad Controls

Mobile

  • Fullscreen

iOS

  • XCode setup

Android

  • Gradle setup

Touchscreen Controls

Split the touchscreen into zones

Action Button

  • One zone is "action", in our case firing the laser

Direction Stick

  • The other is direction. The user start touching at some point, then move right or left: that difference is handled as the direction information.

Consoles?

  • NDA galore