Welcome

This is an introduction workshop for the Bevy Engine.

You can find it at https://vleue.github.io/bevy_workshop/

At the end, you should have an overview of how Bevy works, and a simple 2d platformer.

Target Audience

This workshop is for people wanting to get a better understanding of Bevy basics, with already a good understanding of Rust.

To start with Rust, you can check out these free resources:

This workshop won't use any third party plugins, and won't go very deep into rendering.

Credits

Assets used are from Kenney's Platformer Pack Redux, from Kenney Game Assets All-in-1 or were created for this workshop.

Use A and D to move left and right, and space to jump.

Setup

Clone the repository

git clone https://github.com/vleue/bevy_workshop

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.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 /bin/bash

# Copy the prebuilt target folder
cp -r bevy_workshop/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.wasm

Option 3: Use GitHub Codespace

Go to https://github.com/codespaces/new/mockersf/bevy_workshop, it will use a prebuilt image with everything needed to work in wasm. Increate the number of core as much as you're confortable 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

In this section, we will explore key terms and concepts used in Bevy. We'll go in more depth into how they are used through 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.

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 at build time.

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.

Introduction to Bevy

We'll start our game with a splash screen. To achieve this, we will:

  • Create a new Bevy application
  • Add the default plugins
  • Build a system to spawn entities with components
  • Use states to reduce boilerplate code
  • Refactor our code using plugins

Checkout 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
cd bevy_workshop
cargo add bevy@0.15.0-rc.3

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,
                ..default()
            },
        ))
        .with_children(|p| {
            p.spawn((
                Text::new("Bevy\nWorkshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
                TextLayout::new_with_justify(JustifyText::Center),
            ));
        });
}

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.

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,
                ..default()
            },
        ))
        .with_children(|p| {
            p.spawn((
                Text::new("Bevy\nWorkshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
                TextLayout::new_with_justify(JustifyText::Center),
            ));
        });

    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,
    Menu,
}

#[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,
                ..default()
            },
            StateScoped(GameState::Splash),
        ))
        .with_children(|p| {
            p.spawn((
                Text::new("Bevy\nWorkshop"),
                TextFont {
                    font_size: 130.0,
                    ..default()
                },
                TextLayout::new_with_justify(JustifyText::Center),
            ));
        });

    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::Menu);
    }
}

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,
    Menu,
}

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,
                    ..default()
                },
                StateScoped(GameState::Splash),
            ))
            .with_children(|p| {
                p.spawn((
                    Text::new("Bevy\nWorkshop"),
                    TextFont {
                        font_size: 130.0,
                        ..default()
                    },
                    TextLayout::new_with_justify(JustifyText::Center),
                ));
            });

        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::Menu);
        }
    }
}

For plugins that don't use any configuration, it's possible to expose the build function directly, and use it as a plugin:

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, load_assets))
            .add_systems(Update, switch_to_menu.run_if(in_state(GameState::Splash)));
    }
}

Progress Report

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

What You've learned

Basic Game

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

You will:

  • Load, display, and animate sprites
  • React to user input
  • Query entities and components in more complex scenarios

Checkout the branch:

git checkout 06-basic-game

Displaying Something

We'll just display block of colors for now, as a placeholder. Red is the player, green is the ground.

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

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

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Ground;

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, 1.0, 0.0), Vec2::new(1000.0, 80.0)),
        Transform::from_xyz(0.0, -100.0, 0.0),
        Ground,
        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 can be 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,
    Ground
}
}

And query that component. That would mean the same query would return both the ground 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 entity for the player or the ground but not both.

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

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. A changes the position of the player to the left, and D to the right.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Component)]
struct Player;
fn control_player(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player: Query<&mut Transform, With<Player>>,
) {
    let mut player_transform = player.single_mut();
    if keyboard_input.pressed(KeyCode::KeyA) {
        player_transform.translation.x -= 5.0;
    }
    if keyboard_input.pressed(KeyCode::KeyD) {
        player_transform.translation.x += 5.0;
    }
}
}

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

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_image: Handle<Image>,
    player_layout: Handle<TextureAtlasLayout>,
}

fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    mut texture_atlas_layouts: ResMut<Assets<TextureAtlasLayout>>,
) {
    commands.insert_resource(GameAssets {
        player_image: asset_server.load("spritesheet_players.png"),
        player_layout: texture_atlas_layouts.add(TextureAtlasLayout::from_grid(
            UVec2::new(128, 256),
            7,
            8,
            None,
            None,
        )),
    });
}
}

We're loading the spritesheet as a texture atlas, and saying each sprite is 128px by 256px, with 7 columns and 8 rows, no padding, no offset.

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_image: Handle<Image>,
    player_layout: Handle<TextureAtlasLayout>,
}
#[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, assets: Res<GameAssets>) {
    commands.spawn((
        Sprite::from_atlas_image(
            assets.player_image.clone(),
            TextureAtlas {
                layout: assets.player_layout.clone(),
                index: 0,
            },
        ),
        Transform::from_scale(Vec3::splat(0.5)),
        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(0.0, -100.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/compare/05-intro-to-bevy..06-basic-game

Displaying the Ground

File spritesheet_ground.png has a spritesheet for the ground. Use it instead of the green box.

Tips:

  • Add new fields to the GameAssets resource for the ground
  • The index is column * number_of_columns + line
  • You can use different sprites for the borders.

Player Sprite Animation

It should be flipped when changing direction, so that the sprite is facing the direction the player is movng. You can also animate the player while walking by changing the sprite displayed.

Tips:

  • The Sprite component has a flip_x field
  • You can use a local step counter (adding a parameter mut steps: Local<u32>) in system control_player and changing every 10 steps if the player is moving
  • The Sprite component has a texture_atlas field that can be used to change the index
  • There are two sprites available in the spritesheet to display an animation when walking, with index 0 and 7.

Player Acceleration

Make the player accelerate and decelerate instead of directly changing it's position when a key is pressed.

Tips:

  • Store the player current velocity and target velocity in a new component
  • When a key is pressed, change the target velocity accordingly
  • In a separate system, change the current velocity towards the target velocity
  • Move the player according to its current velocity

Basic Physics

It wouldn't be a platformer if you can't fall. Let's add some "gravity" to the game.

We'll compute an Axis Aligned Bounding Box (or AABB) for the player and the ground, and check for intersections. If there's one, then the player is on the ground. Otherwise, the player is in the air, then move its position down to simulate falling.

#![allow(unused)]
fn main() {
extern crate bevy;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Ground;
use bevy::{
    math::bounding::{Aabb2d, IntersectsVolume},
    prelude::*,
};
fn gravity(
    mut player: Query<&mut Transform, With<Player>>,
    ground: Query<&Transform, (Without<Player>, With<Ground>)>,
) {
    let mut is_on_ground = false;
    let mut player_transform = player.single_mut();

    let player_aabb = Aabb2d::new(
        Vec2::new(
            player_transform.translation.x,
            player_transform.translation.y,
        ),
        Vec2::new(
            128.0 * player_transform.scale.x,
            256.0 * player_transform.scale.y,
        ) / 2.0,
    );

    for ground_transform in &ground {
        let ground_aabb = Aabb2d::new(
            Vec2::new(
                ground_transform.translation.x,
                ground_transform.translation.y,
            ),
            Vec2::new(
                128.0 * ground_transform.scale.x,
                128.0 * ground_transform.scale.y,
            ) / 2.0,
        );

        if ground_aabb.intersects(&player_aabb) {
            is_on_ground = true;
        }
    }
    if !is_on_ground {
        player_transform.translation.y -= 10.0;
    }
}
}

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

Disjoint Queries

Accessing the Transform component both mutably and immutably in the same system is not possible, unless we can prove the two queries are disjoint. The filters do this.

Exercises

Uncomment the gravity system so that it affects the player now.

Falling to Death

Make the game go back to the menu when the player fall offscreen.

Tips:

  • Check if the position of the player is below a certain threshold. Then change state to go back to the menu.

Jumping

You can change sprite when jumping.

Can you jump only when touching ground? Do you want double jump? The jump height to vary depending on the duration of the button press? Can the player control direction during jump? Those will change the feel of your game.

This video goes into a lot of details about jumps (and movements in general) in Celeste: Youtube: Why Does Celeste Feel So Good to Play?

Tips:

  • Split gravity into two systems, one checking if player is on the ground and updating a dedicated component, one handling falling when not on the ground
    • You'll need to keep the "falling" system running after the "on_ground" system
  • Add a new component JumpSpeed(f32). When the player jumps, set the value to something higher than gravity (10.0 in the example above). While the player is jumping, decrease this value until it reaches 0.0
  • If you did the "Player Acceleration" exercise, they can share component and systems

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

Going Further

The "physics" engine we've done is very basic.

Movement is stopped after collisions happened, it would be better to do "shapecasting" on the AABB, find the point of collision, and stop there:

  • Before moving, get the AABB
  • Move it to where the player would be after moving
  • If it collides with something, find the point where it would stop colliding between the two positions
  • Move there instead of the original target

In real life, gravity is not a constant speed downward, it's an acceleration which would increase the speed every unit of time. It should be a force that's applied to the player and modifies its vertical speed.

Depending on the feel you want for your game, you can use a complete physics engine (avian2d or rapier2d) or build your own, tailored to what you want.

Level Loading

In this section, you will 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
  • Displaying the level based on its file

Checkout the branch:

git checkout 07-level-loading

Custom Asset Format

Level Format: The Quick and Dirty Way

Let's go with a basic format that you can manually edit with a good idea of how it should render: emojis to the rescue!

⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜🙂⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜🟩🟩🟩🟩🟩🟩🟩🟩🟩⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜
⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜⬜

Asset Type

To match the basic level format, we'll use a basic type that will just be a vec of vecs of tiles. The struct must derive the Asset trait.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Asset, TypePath)]
struct Level {
    pub tiles: Vec<Vec<Tile>>,
}

enum Tile {
    Empty,
    Ground,
}
}

Asset Loader

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 {pub tiles: Vec<Vec<Tile>>}
enum Tile {Empty, Ground}
#[derive(Default)]
struct LevelLoader;

#[derive(Debug, Error)]
enum LevelLoaderError {
    #[error("Could not load asset: {0}")]
    Io(#[from] std::io::Error),
    #[error("Unknown tile: {0}")]
    UnknownTile(char),
}

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 tiles = vec![];
        let mut line = vec![];
        for char in buf.chars() {
            match char {
                '⬜' => line.push(Tile::Empty),
                '🟩' => line.push(Tile::Ground),
                '🙂' => (),
                '\n' => {
                    tiles.push(line);
                    line = vec![];
                }
                char => Err(LevelLoaderError::UnknownTile(char))?,
            }
        }
        Ok(Level { tiles })
    }

    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 {pub tiles: Vec<Vec<Tile>>}
enum Tile {Empty, Ground}
#[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"] }
}
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 {pub tiles: Vec<Vec<Tile>>}
enum Tile {Empty, Ground}
#[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 {pub tiles: Vec<Vec<Tile>>}
enum Tile {Empty, Ground}
#[derive(Resource)]
struct GameAssets {
    player_image: Handle<Image>,
    player_layout: Handle<TextureAtlasLayout>,
    ground_image: Handle<Image>,
    ground_layout: Handle<TextureAtlasLayout>,
}
#[derive(Resource)]
pub struct LoadedLevel { pub level: Handle<Level> }
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Ground;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
fn ground_tile_index(line: &[Tile], i: usize) -> usize {
    match (
        i == 0 || !matches!(line.get(i - 1).unwrap_or(&Tile::Empty), Tile::Ground),
        !matches!(line.get(i + 1).unwrap_or(&Tile::Empty), Tile::Ground),
    ) {
        (true, true) => 8,
        (true, false) => 14,
        (false, true) => 0,
        (false, false) => 7,
    }
}

fn display_tile(
    commands: &mut Commands,
    tile: &Tile,
    i: usize,
    x: f32,
    y: f32,
    line: &[Tile],
    assets: &GameAssets,
) {
    match tile {
        Tile::Ground => {
            let index = ground_tile_index(line, i);
            commands.spawn((
                Sprite::from_atlas_image(
                    assets.ground_image.clone(),
                    TextureAtlas {
                        layout: assets.ground_layout.clone(),
                        index,
                    },
                ),
                Transform::from_xyz(x, y, 0.0).with_scale(Vec3::splat(0.5)),
                Ground,
                StateScoped(GameState::Game),
            ));
        }
        Tile::Empty => {}
    }
}

fn display_level(
    mut commands: Commands,
    assets: Res<GameAssets>,
    level: Res<LoadedLevel>,
    levels: Res<Assets<Level>>,
) {
    let level = levels.get(&level.level).unwrap();

    for (j, line) in level.tiles.iter().enumerate() {
        for (i, tile) in line.iter().enumerate() {
            let (x, y) = ((i as f32 - 9.0) * 64.0, -(j as f32 - 5.0) * 64.0);
            display_tile(&mut commands, tile, i, x, y, line, &assets);
        }
    }

    commands.spawn((
        Sprite::from_atlas_image(
            assets.player_image.clone(),
            TextureAtlas {
                layout: assets.player_layout.clone(),
                index: 0,
            },
        ),
        Transform::from_xyz(0.0, 200.0, 0.0).with_scale(Vec3::splat(0.5)),
        StateScoped(GameState::Game),
        Player,
    ));
}
}

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/compare/06-basic-game..07-level-loading

Handle the Player Starting Position

Spawn the player where there's a smiley 🙂

Tips:

  • Return an error if there's more than one 🙂 in the level
  • Switch its position by 32.0 on the y axis

Make a Fun Level

Tips:

  • Have fun!

Try Hot Reloading

Tips:

  • 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

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
  • Hot reloading

Going Further

The level format we've done is good for a quick game but is limited. You should either:

  • Use an existing level editor, like LDtk (Level Designer toolkit) or Tiled, which are very powerful and supported in Bevy through third party plugins
  • Or build your own, that will allow you to include specific features for your game

Action Zones

In this section, you will learn how to create and manage action zones within your game. Action zones are specific areas that trigger certain behaviors or events when a player interacts with them.

You will learn how to:

  • Define these zones
  • Implement the logic for triggering actions
  • Integrate them into your game to enhance interactivity and gameplay dynamics

Checkout the branch:

git checkout 08-action-zones

Adding a Winning Zone

Add a Winning Zone to the Level

By adding a new emoji to our level, we can add something new. Let's add a winning zone with a 🏁 emoji.

First we'll add a new variant to the Tile enum: Flag:

#![allow(unused)]
fn main() {
pub enum Tile {
    // ...
    Flag,
}
}

Then parse it in our LevelLoader:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::{asset::{io::Reader, AssetLoader, AsyncReadExt, LoadContext}, prelude::*};
#[derive(Asset, TypePath)]
struct Level {pub tiles: Vec<Vec<Tile>>}
enum LevelLoaderError {UnknownTile(char)}
enum Tile {Flag}
struct LevelLoader;
trait ShortLoader {
    type Error;
    type Asset;
    async fn load() -> Result<Self::Asset, Self::Error>;
}
impl ShortLoader for LevelLoader {
type Error = LevelLoaderError;
type Asset = Level;
async fn load(/* ... */) -> Result<Self::Asset, Self::Error> {
    let buf = String::new();
    let mut tiles = vec![];
    let mut line = vec![];
    // ...
    for char in buf.chars() {
        match char {
            // ...
            '🏁' => line.push(Tile::Flag),
            char => Err(LevelLoaderError::UnknownTile(char))?,
        }
    }
    Ok(Level { tiles })
}
}
}

Displaying the Zone

We'll use a new spritesheet, spritesheet_items.png, to have items to display.

First we'll add new fields to the GameAssets resource to hold the new handles:

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

Then load the new spritesheet during the splash screen:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
let commands: Commands = unimplemented!();
let texture_atlas_layouts: Assets<TextureAtlasLayout> = unimplemented!();
let asset_server: AssetServer = unimplemented!();
#[derive(Resource)]
struct GameAssets {
    items_image: Handle<Image>,
    items_layout: Handle<TextureAtlasLayout>,
}
commands.insert_resource(GameAssets {
    // ...
    items_image: asset_server.load("spritesheet_items.png"),
    items_layout: texture_atlas_layouts.add(TextureAtlasLayout::from_grid(
        UVec2::new(128, 128),
        6,
        4,
        None,
        None,
    )),
});
}

And finally we'll display the flag in display_tile:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
struct GameAssets {
    items_image: Handle<Image>,
    items_layout: Handle<TextureAtlasLayout>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
enum Tile { Flag }
#[derive(Component)]
struct Flag;

fn display_tile(/* ... */) {
    let assets: GameAssets = unimplemented!();
    let commands: Commands = unimplemented!();
    let (x, y) = (0.0, 0.0);
    let tile = Tile::Flag;
    match tile {
        // ...
        Tile::Flag => {
            commands.spawn((
                Sprite::from_atlas_image(
                    assets.items_image.clone(),
                    TextureAtlas {
                        layout: assets.items_layout.clone(),
                        index: 6,
                    },
                ),
                Transform::from_xyz(x, y, 0.0).with_scale(Vec3::splat(0.5)),
                StateScoped(GameState::Game),
                Flag,
            ));
        }
    }
}
}

Z-Index

If you play a few times, you may notice that the order of sprites varies: sometimes the alien is in front of the flag, sometimes behind. This can be controlled with the Z index.

Everything we've displayed up till now, we've used O.O for the z value when calling Transform::from_xyz. By using the same value for every sprite, we're not telling the engine the order they should be displayed. In Bevy, higher values are displayed in front.

Winning the Game

We'll send an event when the player gets near the flag, then react to it to win the game.

We'll need a system to trigger the event and another to react to it.

Declare an event

First we'll declare an event that we can trigger when the player is near the flag.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
struct ReachedFlag;
}

Triggers

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

Here is the entity specific version to trigger the event:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
struct ReachedFlag;
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Flag;
fn near_flag(
    mut commands: Commands,
    player_transform: Query<&Transform, With<Player>>,
    flags: Query<(Entity, &Transform), With<Flag>>,
) {
    let player_transform = player_transform.single();
    for (flag, flag_transform) in &flags {
        if player_transform
            .translation
            .distance(flag_transform.translation)
            < 50.0
        {
            commands.entity(flag).trigger(ReachedFlag);
        }
    }
}
}

The near_flag system is added to the player_plugin:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
fn near_flag(){}
fn player_plugin(app: &mut App) {
    // ...
    app.add_systems(FixedUpdate, near_flag.run_if(in_state(GameState::Game)));
}
}

Observers

To react to the trigger, we use a system that takes a Trigger as a system parameter, plus any other parameter needed.

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
struct ReachedFlag;
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Menu }
fn reached_flag(_trigger: Trigger<ReachedFlag>, mut next: ResMut<NextState<GameState>>) {
    next.set(GameState::Menu);
}
}

And the reached_flag observer is added to the Flag entity:

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
enum Tile { Flag }
#[derive(Component)]
struct Flag;
#[derive(Event)]
struct ReachedFlag;
fn reached_flag(_trigger: Trigger<ReachedFlag>) {}
fn display_tile(/* ... */) {
    let commands: Commands = unimplemented!();
    let (x, y) = (0.0, 0.0);
    let tile = Tile::Flag;
    match tile {
        // ...
        Tile::Flag => {
            commands
                .spawn((
                    // ...
                    Flag,
                ))
                .observe(reached_flag);
        }
    }
}
}

Exercises

Don't forget to checkout the branch:

git checkout 08-action-zones

Let's review what was changed: https://github.com/vleue/bevy_workshop/compare/07-level-loading..08-action-zones

Use Fixed Z Indexes

Use fixed values for the z indexes so that the game renders the same every time.

Tips:

  • Look for every from_xyz and replace the third value by a different index
  • Ground in the back, then flag, then player in the front

Switches

Add a switch zone that enables the flag in the level.

Tips:

  • Create a new tile in the level and display it
  • Start with the flag disabled
    • Add a bool to the component: Flag(bool)
    • When false, don't trigger winning when reaching the flag
  • When getting near the switch, change the Flag component to true
  • You can use different sprites to show whether the flag is enabled or not

PowerUps

Add an item to pick up that change how the player jumps.

Tips:

  • Create a new tile in the level and display it
  • When getting near the powerup, add a new component to the player
  • In the system controlling player jumps, query for the optional component
  • If the component is present, change how jumping behaves

Progress Report

What You've Learned

  • How Z-Index works in 2d: higher values are in front
  • How to implement reactivity
    • By using Trigger and observers
    • Or with optional components to change how an existing query behaves

Going Further

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

Sound Effects

In this section, you will 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

Checkout the branch:

git checkout 09-sound-effects

Jumping

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)]
struct AudioAssets {
    jump: Handle<AudioSource>,
}

fn load_assets(
    mut commands: Commands,
    asset_server: Res<AssetServer>,
    // ...
) {
    commands.insert_resource(AudioAssets {
        jump: asset_server.load("jump.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)]
enum AudioTrigger {
    Jump,
}
}

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

#![allow(unused)]
fn main() {
extern crate bevy;
use bevy::prelude::*;
#[derive(Event)]
enum AudioTrigger {Jump}
#[derive(Component)]
struct Velocity {jumping: f32}
#[derive(Component)]
struct IsOnGround(f32);
#[derive(Component)]
struct Player;
fn control_player(
    keyboard_input: Res<ButtonInput<KeyCode>>,
    mut player: Query<(&mut Velocity, &IsOnGround), With<Player>>,
    time: Res<Time>,
    mut audio_triggers: EventWriter<AudioTrigger>,
) {
    // ...
    let mut velocity = Velocity { jumping: 0.0 };
    let is_on_ground = IsOnGround(0.0);
    if time.elapsed_secs() - is_on_ground.0 < 0.5 && keyboard_input.pressed(KeyCode::Space) {
        if velocity.jumping == 0.0 {
            audio_triggers.send(AudioTrigger::Jump);
        }
        velocity.jumping = 15.0;
    }
}
}

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 AudioTrigger {Jump}
#[derive(Resource)]
struct AudioAssets { jump: Handle<AudioSource> }
fn play_audio(
    mut commands: Commands,
    mut audio_triggers: EventReader<AudioTrigger>,
    sound_assets: Res<AudioAssets>,
) {
    for trigger in audio_triggers.read() {
        match trigger {
            AudioTrigger::Jump => {
                commands.spawn((
                    AudioPlayer::<AudioSource>(sound_assets.jump.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 AudioTrigger {Jump}
fn play_audio() {}
fn audio_plugin(app: &mut App) {
    app.add_event::<AudioTrigger>()
        .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/compare/08-action-zones..09-sound-effects

Other Events

Add sound for game start, winning and losing.

Tips:

Background Music

Add a background music

Tips:

Audio Settings

Audio volume should always be configurable. This is important for accessibility. Add a way to control volume of all audio, or even better ways to control separately the volume of the background music and of the audio effects.

Tips:

  • PlaybackSettings can be used to control volume of an audio
  • You can add +/- buttons on the menu screen that control the volume
  • Store the current volume in a resource, and use it when spawning new entities to play audio

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

Visual effects can help your game pop up. This is commonly done with shaders, which are programs that execute on the GPU. The best languages to write them in Bevy is the WebGPU Shading Language, and it will be translated as needed by the platform on which the application is running.

Bevy offers several abstractions to render things on screen:

  • Directly using images or colors or texture atlas, which is what we've been doing until now. The shaders are built-in Bevy, and use as many optimisation as possible at the cost of customisation.
  • Custom materials, which we'll explore in this section. For 2d, you'll need to implement the Material2d trait.
  • Lower level abstractions, down to complete control on the whole rendering pipeline. This won't be in this workshop.

Checkout the branch:

git checkout 10-visual-effects

Dynamic Flag

We'll build a first shader adding some particles to the flag depending on how close the player is.

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},
    sprite::{AlphaMode2d, Material2d, Material2dPlugin},
};
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FlagMaterial {
    #[texture(0)]
    #[sampler(1)]
    pub atlas: Handle<Image>,
    #[uniform(2)]
    pub index: Vec4,
    #[uniform(3)]
    pub distance: Vec4,
}
}

By deriving the AsBindGroup trait and annotating the field of the struct, Bevy will be able to know how to transform the data from Rust type to what is expected by the GPU:

  • atlas has the handle to the spritesheet
  • index is the index of the sprite in the spritesheet. Bevy uses a single u32 for that, and get the number of rows and columns from the TextureAtlasLayout. We'll do simpler and hard code some values, and use (i, j) coordinatesto specify which sprite to use
  • distance is the distance between the flag and the player

index will have a Vec2, and distance a f32, but they are both defined as Vec4. This is for WebGL2 compatibility, where types must be aligned on 16 bytes.

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.

This workshop use padding as it's easier to read and the material is only used once, so doesn't waste a lot of memory.

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 FlagMaterial {}
impl Material2d for FlagMaterial {
    fn fragment_shader() -> ShaderRef {
        "flag_shader.wgsl".into()
    }

    fn alpha_mode(&self) -> AlphaMode2d {
        AlphaMode2d::Blend
    }
}
}

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,
}

@group(2) @binding(0) var base_color_texture: texture_2d<f32>;
@group(2) @binding(1) var base_color_sampler: sampler;
@group(2) @binding(2) var<uniform> index: vec4<f32>;
@group(2) @binding(3) var<uniform> distance_to_player: vec4<f32>;

@fragment
fn fragment(mesh: VertexOutput) -> @location(0) vec4<f32> {
    let atlas_width = 1024.0;
    let atlas_height = 512.0;
    let sprite_size = 128.0;

    var texture = textureSample(
        base_color_texture,
        base_color_sampler,
        vec2<f32>((mesh.uv.x + index.x) * sprite_size / atlas_width, (mesh.uv.y + index.y) * sprite_size / atlas_height)
    );

    return texture;
}

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},
    sprite::{AlphaMode2d, Material2d, Material2dPlugin},
};
#[derive(Asset, TypePath, AsBindGroup, Debug, Clone)]
pub struct FlagMaterial {}
impl Material2d for FlagMaterial {}
fn flag_plugin(app: &mut App) {
    app.add_plugins(Material2dPlugin::<FlagMaterial>::default());
}
}

Then we can replace Sprite for the flag with our new material:

#![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 FlagMaterial {
    #[texture(0)]
    #[sampler(1)]
    pub atlas: Handle<Image>,
    #[uniform(2)]
    pub index: Vec4,
    #[uniform(3)]
    pub distance: Vec4,
}
impl Material2d for FlagMaterial {}
enum Tile { Flag }
#[derive(Component)]
struct Flag;
#[derive(Event)]
struct ReachedFlag;
fn reached_flag(_trigger: Trigger<ReachedFlag>) {}
struct GameAssets {
    items_image: Handle<Image>,
    items_layout: Handle<TextureAtlasLayout>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, States, Default)]
enum GameState { #[default] Game }
fn display_tile(
    // ...
    meshes: &mut Assets<Mesh>,
    flag_materials: &mut Assets<FlagMaterial>,
) {
    let commands: Commands = unimplemented!();
    let assets: GameAssets = unimplemented!();
    let (x, y) = (0.0, 0.0);
    let tile = Tile::Flag;
    match tile {
        // ...
        Tile::Flag => {
            commands
                .spawn((
                    Mesh2d(meshes.add(Rectangle::default())),
                    MeshMaterial2d(flag_materials.add(FlagMaterial {
                        atlas: assets.items_image.clone(),
                        index: Vec4::new(0.0, 1.0, 0.0, 0.0),
                        distance: Vec4::ZERO,
                    })),
                    Transform::from_xyz(x, y, 1.0).with_scale(Vec3::splat(0.5) * 128.0),
                    StateScoped(GameState::Game),
                    Flag,
                ))
                .observe(reached_flag);
        }
        // ...
    }
}
}

✍️ 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/compare/09-sound-effects..10-visual-effects

Jumping

Let's add a shader displaying an effect when jumping.

Tips:

  • Use the time the player started jumping in the material
  • Use the current velocity in the material
  • Try to find a cool effect on https://www.shadertoy.com and port it

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

Going Further

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

Enemies

This part is left as an exercise to the avid reader. Use it to expand on all you've learned until now. An asset spritesheet_enemies.png is provided with some sprites that can be used.

Add Enemy Locations to the Level

Tips:

  • Add a new emoji and place it in the level
  • Add a new tile type and parse the emoji to it

Load Assets and Display Them

Tips:

  • Load the new spritesheet in the load_assets system
  • Add a new marker component
  • Spawn the enemy when displaying the level with the marker component

Add "AI"

You should decide how this enemy will act:

  • Will it be stationary?
  • Will it walk back and forth on a platform?
  • Will it wait for the player to come close then rush to them?

Tips:

  • Add a new system with a query on your marker component
  • If it needs to know the ground, add a query with the Ground entities
  • If it needs to know the position of the player, add a query with the Player entity

Collisions With Enemy - Their Death, or Yours

If the enemy touch the player, what happens? Does it depend on the side that was touched? Can enemies be stomped on?

Tips:

  • Add a new system with a query on your marker component and another on the Player entity
  • Compute their AABB and find if they intersects
  • Find on which side the player is
  • Either kill the enemy (despawn the entity) or the player (switch state back to menu)

Juice it up!

Enemies are several sprites, use them to show an animation. Add audio effects when they collide with the player. Use a visual effect to change their look when they get killed. Add more kind of enemies!

Platforms Support

Native

Crossbuilding?

wasm

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

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 jump

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

What's Next (Game)

Points

Timer

Win/Lose Screen

Camera Management

Youtube: How to Make a Good 2D Camera

Follow the player

Lookahead

Offset the camera in the forward direction: dedicate more of the screen to where the player is going.

Don't follow on the Y axis

On the Y axis, don't follow the player when they jump. Instead, follow when they land on a platform so that the ground is always at the same level.

Damping

Don't move the camera as soon as the player moves, but as if it was bound to the player by an elastic.

Screen Shake and Juice

Fun effect when something happens.

More than 1 Level

More Game Mechanics (Enemies, PowerUps, ...)

Make it Fun!

What's Next (Bevy)

On Screen Debugging With Gizmos

Third Party Plugins

Rendering

Reflection

Debugging and Benchmarking

Gizmos

Try:

cargo run --features debug

Logging

Tracing