snen.dev blog

Building With Bevy

Part 1 - Getting Started with a Strong Development Environment

This series is about writing a Bevy project. If you aren't familiar with this series, start here to read about the background.

This post will describe my development environment and overall development strategy. This includes not only the tools I use that streamline development, but also the way I organize my Rust crates, and my use of "test scenes" that act as sandboxes for game design and development as different features are explored. Before we begin, I will start with a brief ECS introduction, so that there is some context behind the methodology.

If you're interested in following along, I recommend continuing below. Also, feel free to checkout the associated commit in the workbook repository to see the end result. If you would rather skip ahead, feel free to move on to the next post in the series.

Directory Setup

We're going to add a few different directories in a cargo workspace as this series continues. It starts by creating a workspace, which we can do by adding a Cargo.toml:

[workspace]
resolver = "2"

...and running cargo init crabber (yes, "Crabber" is what we're calling it) to initialize a crate for our game code. Any time we add a crate to our workspace, that crate's Cargo.toml should include the following line:

# crabber/Cargo.toml
workspace = "<path-to-root>"

...and then the paths to any such crates should be added to the list of members in the root:

# Cargo.toml
[workspace]
resolver = "2"
members = [ "crabber" ]

Entities, Components, Systems

If you have never seen Bevy code before, I suggest taking a quick read of the docs, which go through ECS basics. I will also reference the Bevy unofficial Cheatbook a few times in these posts, it's a great resource.

In short, Entities are simple objects containing an ID that are used to represent game object "instances". A Component is some data type that can be "attached" to entities. Then, each System in the program can operate on an entity's component data to define game behavior.

Let's break this down further. With the above, an ECS strictly separates game entity objects from the objects containing stateful data. Why would this be useful? To answer this question, it's helpful to think about the specific ways that game features interact. Each feature might rely on the interaction between distinct properties of the total game state, which may manage a wide universe of different data kinds.

Let's consider some examples of state. In many games, a player character may be associated with many unrelated kinds of state: character resources like Health Points, Equipment, or Ammunition are common, but this also includes state like "have I double-jumped" or "am I in hit-stun" or whatever else might be relevant data about the game.

When it comes to individual game behaviors, these can often be described with a small subset of the types in state. For example, if a character has HP and some number of "lives" before gameover, we can detect when the player should lose a life by only detecting when HP depletes to 0. As another example, in a turn-based game, turn order might be specifically determined by each character's Speed stat. By separating out the data associated with each game object into smaller structs, we can disentangle the different needs of each entity and each particular behavior. This makes it much easier to express game behavior in concise and modular chunks. In an ECS, the functions that define these behaviors are written as systems. In the first case above, we could define a single system that implements the interaction between HP and lives, allowing us as developers to guarantee that the system always performs the expected behavior when those two components are attached to the same entity. When building a game with many different features, as development continues, this type of locality and modularity can drastically improve the time required to make adjustments or refactors.

With all this in mind, we can reconsider how the term "entity" reflects its behaviorless nature: it is simply a thing in the game world. And if each entity is a thing, then each component type is some data that a thing could have. By attaching a given component instance to an entity, we associate that thing with that data. Then, to define the behavior that operates on those things, systems can query the game world for all entities that have the relevant component data and execute only the specific expected behavior.

Another important example is perhaps the most commonly shared problem in video game development: we have to render game objects to the screen. Without any components and systems that specify how to do that rendering, in an ECS, a spawned entity would simply not appear on screen. To render a sprite for some entity, we need components that can track both the sprite data to render and also the intended position on the screen for that sprite. Then, we need systems that use these components to paint the sprites into the application window. (Bevy provides such components and systems out-of-the-box, so don't worry -- this is trivial in practice.)

In order to provide this API, functions used as systems have to follow a few key rules. In Bevy, these rules enforce what arguments a system can accept, so that the application scheduler can call each system with the appropriate data from its internal state. The rules are implemented by the IntoSystem and SystemParam traits, which are automatically implemented for any functions with valid argument types. A short list of the most common argument types includes:

  • A "Query" (descriptive bevy example) which enables iterating through entities that match the query or getting component values for a specific entity as-needed.
  • A "Resource" (queried using the Res and ResMut types) which accesses a global singleton object attached to the World.
  • The Commands struct, which enables specific world operations like spawning entities or attaching components. These are buffered, so a spawned entity will not be immediately referenceable until the queue flushes.
  • Any custom or 3rd-party-defined SystemParams, which usually offer protected access to some global resource (and in many cases are useful APIs wrapping Resource orQuery behaviors).
  • Alternatively, a (mutable) reference to World, which is, well, the whole "world" simulated by the App. This signature defines an exclusive system, which cannot run in parallel with other systems. But since you can control the whole World, they are useful for a variety of specific use-cases.

These tools provide the basic operations needed to run an ECS-based application. Under-the-hood, Bevy (and other ECS frameworks) use this "system" structure to maximize parallelism and performance when executing the logic of the overall system. To do so without creating undefined behavior, Bevy needs to ensure that data is accessed in safe ways. For example, Bevy shuld not not provide mutable access to some global Resource or some Component to more than one system at a time, and so it must schedule systems in a way that obeys these rules.

Hopefully this serves as a reasonably-quick run-down of how this all works. In order to keep things moving, I'll defer to some other existing resources that provide more information if needed.

App & Plugins

The root of an application made with Bevy is the App struct. It maintains the world object and other runtime-level information. To build an app with Bevy, create an App and attach the plugins, systems, resources, and events that describe your application.

To start, from crabber/main.rs, we can add the DefaultPlugins which give the basic out-of-the-box utilities that Bevy expects a typical game developer to need.

// main.rs
use bevy::app::{App, DefaultPlugins};

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

Upon executing cargo run, a window spawns and then exits. That's because we don't have any systems to run, so the app is "done," and it unwinds.

Let's add a graphics.rs and add some code to spawn a camera:

// graphics.rs
use bevy::{
    prelude::{Camera, Camera2dBundle, Commands, Plugin, Query, With,
    },
    render::{camera::Viewport, view::RenderLayers},
    window::{PrimaryWindow, Window},
};

// a "startup system" that spawns a camera when run
// attach with `add_startup_system` or in an `OnEnter` schedule. 
fn camera(mut commands: Commands, window: Query<&Window, With<PrimaryWindow>>) {
    commands.spawn(Camera2dBundle::default());
}

Then we can attach this system to the app in our main.rs:

// main.rs
mod graphics;

fn main() {
    App::new()
        .add_default_plugins(DefaultPlugins)
        .add_startup_system(graphics::camera)
        .run();
}

This App type is what ties together the various pieces of system code that comprise our game. However, it is useful for encapsulation purposes to define portions of this logic that group various behaviors and systems together. Bevy enables this through the Plugin trait: by implementing Plugin on some struct, we can attach some group of system behaviors to the App all at once. We will add plenty more graphics features later, so let's define a GraphicsPlugin where we can keep any graphics-related code (such as spawning our camera).

// graphics.rs
pub struct GraphicsPlugin;

impl Plugin for GraphicsPlugin {
    fn build(&self, app: &mut App) {
        app.add_startup_system(camera);
    }
}

Then, in main.rs, we can attach the plugin instead of the individual system:

// main.rs
mod graphics;
use graphics::{GraphicsPlugin as CrabGraphicsPlugin};

fn main() {
   App::new()
      .add_default_plugins(DefaultPlugins)
      .add_startup_system(CrabGraphicsPlugin)
      .run();
}

In future posts, to maximize code reusability and flexibility, the aim will often be to create the most useful plugins. Useful and flexible plugins massively streamline the ability to port logic between different environments such as different application states, test environments, and whatever else.

Assets

In order to build the graphics features of a game, we need some way to load in files that contain sprite data. Such files are often called assets, and they are a core part of the development workflow for any game. Let's extend GraphicsPlugin to attach some simple assets.

To get started, I found this crab on opengameart.org which has a 4-by-2 tilemap of 64px-by-64px crab sprites, and saved it at assets/spritesheets/crab.png.

The bevy_asset_loader crate provides a lot of tools for managing assets. (There are other options for asset loading, but this is the one I'm most familiar with.) This crate provides convenient methods and macros that facilitate defining resources that can load assets and yield assets to systems. The easiest way to load assets into the application is to define a "loading state":

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, States)]
pub enum AssetsState {
    #[default]
    Waiting,
    Loading,
    Loaded,
}

// ...
app
    .add_state::<AssetState>()
    .add_loading_state(
        LoadingState::new(AssetState::Loading)
            .continue_to_state(AssetState::Loaded)
    )

In this example, the AssetsState enum provides three possible variants. It begins in the Waiting state, and once some other system sets the state to Loading, it will start loading assets. Once complete, it will trigger a transition to the Loaded state. In other game systems, we can use these states to decide when it is valid to read from our asset resources.

For our purposes, it's sufficient to load all our assets on startup. The snippet below makes some adjustments for simplicity and shows how we can register asset types to be loaded with these tools in place.

// resources.rs
use bevy::{
    asset::{AssetServer, Assets},
    prelude::Resource,
    sprite::TextureAtlas,
};
use bevy_asset_loader::asset_collection::AssetCollection;

// The `AssetCollection` trait allows bevy_asset_loader to load assets associated with each field. 
#[derive(Resource, AssetCollection)]
pub struct SpriteSheetAssets {
    // These next two lines are "macro helper attributes" which bevy_asset_loader exposes
    // to support deriving the `AssetCollection` trait.
    // They are used to define how the image file should be segmented into tiles.
    #[asset(texture_atlas(tile_size_x = 64., tile_size_y = 64., columns = 4, rows = 2))]
    #[asset(path = "spritesheets/crab.png")]
    pub crab: Handle<TextureAtlas>,
}

// lib.rs
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, States)]
pub enum AppState {
    #[default]
    Loading,
    InGame,
}

// graphics.rs
app
    .add_state::<AppState>()
    .add_loading_state(
        LoadingState::new(AppState::Loading)
            .continue_to_state(AppState::InGame)
    )
    // this line registers our AssetCollection for loading
    .add_collection_to_loading_state::<_, SpriteSheetAssets>(AppState::Loading)

Finally, we can add a system that spawns a sprite:

// graphics.rs
fn spawn_sprite(mut commands: Commands, spritesheets: Res<SpriteSheetAssets>) {
    commands.spawn(SpriteSheetBundle {
        texture_atlas: spritesheets.crab.clone(),
        sprite: TextureAtlasSprite::new(0),
        ..Default::default()
    });
}

// inside GraphicsPlugin::build
app.add_startup_system(spawn_sprite);

If we try to run this with cargo run... oh no! It panics because we the SpritesheetAssets resource is not loaded yet! That's because we're currently running these systems as startup systems, meaning they run as soon as the app starts. Now that we're loading assets, we need to wait until we enter the AppState::InGame state before running the camera and sprite systems. For this purpose, Bevy provides the OnEnter schedule:

use bevy::prelude::{...,  IntoSystemAppConfig, OnEnter};

fn build(&self, app: &mut App) {
    app // ...
        .add_system(camera.in_schedule(OnEnter(AppState::InGame)))
        .add_system(spawn_sprite.in_schedule(OnEnter(AppState::InGame)));
}

Now, running our test shows a very friendly crabby! So, we now know how to add assets to our build with ease.

Tests

At this point, before we continue onto developing the game, it's useful to pause for a moment. We could add the above systems to the main file and go from there. However, we will eventually lose information that way: we will make plenty of changes to this main file so that the game has all the features we need, like UIs and game behavior. In short, this includes plenty of things that aren't related to our sprites. If we want some executable that helps guarantee in perpetuity that GraphicsPlugin does what we expect, we won't have a way to do so.

With tests, it's possible to create scenes that represent the many specific states and system combinations that we may want to evaluate while developing some feature or before publishing an update. Ideally, we could automate these tests to help identify regressions as development continues.

I saw a discussion on testing in Bevy a while ago in a blog post by chadnauseum which I thought did a good job of explaining why this type of "test scene" approach would be useful. I read this at a time where I was already trying to think about how to use Cargo to get more out of my developer experience, and it provided a number of useful ideas.

As we go through this, I will primarily use end-to-end tests to visualize our game behavior and to act as manual regression checks. I think there's a lot more that can be done here too; I do not show any specific strategy for unit testing, for example, and I'm excited about the possibility to create snapshot tests that save and replay test inputs or output video records of test runs. I won't implement such things here, but hopefully this shows why testing-based development is a critical priority for this project.

End-to-End Tests

So far, the most useful approach for me has involved writing "end-to-end" tests using the individual Plugins for each slice of game behavior. This makes for a very nice development environment, since I can literally see my game as I develop, and I can test distinct sets of behavior without too many confounding factors. Also, I can add whatever other useful features I want that can improve the debugging experience in that GUI or the terminal.

To make our tests work nicely, we need to add a bit of configuration. We want end-to-end tests to do things like spawn windows, which means interfacing with the winit event loop, which means we need to run on the main thread. Cargo's default test harness won't allow that, but you can make Cargo aware of test files and disable the default test harness so that they will run on the main thread:

# Cargo.toml
[[test]]
name = "e2e-crab-sprite"
path = "e2e/crab-sprite.rs"
# Do not use Cargo's default test harness
# but instead run it like a more typical binary
harness = false

There are probably other strategies here, but this one has been plenty convenient for me.

Since we want to import code from our game code crate into these tests, we'll need to add a lib.rs file that exports our game plugins. Then, the main executable will also import those types in main.rs:

// lib.rs
mod graphics;
pub use graphics::GraphicsPlugin;
pub mod resources;

// main.rs
use bevy::prelude::*;
use crabber::{GraphicsPlugin as CrabGraphicsPlugin};

fn main() {
    App::default()
        .add_plugins(DefaultPlugins)
        .add_plugin(CrabGraphicsPlugin);
}

Now we can create some e2e/crab-sprite.rs using the code currently in main. main doesn't really need to execute our spawn_sprite system -- the sprite will be spawned at some moment when we spawn the level. So it makes sense to remove that code from GraphicsPlugin, and keep it as a test utility. That leaves us with the following test file:

// e2e/crab-sprite.rs
use bevy::prelude::*;
use crabber::{GraphicsPlugin as CrabGraphicsPlugin, SpriteSheetAssets};

fn spawn_sprite(mut commands: Commands, spritesheets: Res<SpriteSheetAssets>) {
    commands.spawn(SpriteSheetBundle {
        texture_atlas: spritesheets.crab.clone(),
        sprite: TextureAtlasSprite::new(0),
        ..Default::default()
    });
}

fn main() {
    App::default()
        .add_plugins(DefaultPlugins)
        .add_plugin(CrabGraphicsPlugin)
        .add_system(spawn_sprite.in_schedule(OnEnter(AppState::InGame)));
}

With this approach, we can create as many test examples as we want. To invoke them, run cargo test --test <test-name>. Right now, this test looks the same as main, but that's only because we haven't built anything else yet.

Unit Tests

To support unit tests, we need to do a little more work. The chadnauseum post discussed previously has a solution involving TestPlugins which was not added to the Bevy ecosystem (for understandable reasons -- different consumers may have significantly different testing needs). But we ideally would build some TestPlugins for use in our testing environments.

There is an additional important detail when trying to unit test Bevy. The how to test systems Bevy example shows how it is intended to work: once the relevant systems for the test are added, unit tests should manually execute some number of ticks with app.update(). From there, the application can make whatever assertions are required for the tests.

I would love to do more here and create some clean generalization to work with, but I'll leave furthering this idea for a later time.

Generalizing an end-to-end solution

Since we're focused for now on end-to-end tests to aid development, it's useful to create some layer of abstraction that can easily load us into a common test environment.

Let's a new package to our workspace, which I will call common-e2e, and add bevy as a dependency. Since we will add more such utilities in later posts, I added this crates to a new folder named lib, where I will add any useful crates in the future. I can include anything we add into lib all at once in the workspace manifest using a wildcard:

# Cargo.toml
[workspace]
resolver = "2"
members = [
    "crabber"
    "lib/*", # <--
]

Now let's implement the common-e2e wrapper, which I will call Test. This struct builds the application with some appropriate set of base plugins that are useful for our debug environment. Later on, we could extend this to support both unit and end-to-end tests.

// common_e2e/src/lib.rs
use std::thread;

use bevy::{
    app::{App, PluginGroup, PluginGroupBuilder},
    log::LogPlugin,
    render::settings::WgpuSettings,
    time::Time,
    winit::WinitSettings,
    DefaultPlugins,
};

// Check to make sure that we're on the main thread so that we can warn if not.
fn on_main_thread() -> bool {
    println!("thread name: {}", thread::current().name().unwrap());
    matches!(thread::current().name(), Some("main"))
}

pub struct Test<A> {
    // always useful to have a label :)
    pub label: String,
    // this is where our test systems will be registered
    pub setup: fn(&mut App) -> A,
    // will be useful when we don't want to render things in unit tests
    pub setup_graphics: fn(&mut App),
    // these are not useful yet but are a possible way to extend this to unit tests
    // pub frames: u64,
    // pub check: fn(&App, A) -> bool,
}

impl<A> Test<A> {
    pub fn run(&self) {
        // ensure no one tries to run this from the default cargo test harness
        let on_main_thread = on_main_thread();
        assert!(
            on_main_thread,
            "end-to-end test must be run on main thread!"
        );
        let app = App::new();
        // ensures that closing the window "returns" the bevy app
        app.insert_resource(WinitSettings {
            return_from_run: true,
            ..Default::default()
        })
        .add_plugins(DefaultPlugins)
        // a quick utility for closing the test environment with a single keypress
        .add_system(bevy::window::close_on_esc);
        (self.setup)(&mut app);
        (self.setup_graphics)(&mut app);
        app.run();
    }
}

With this, our crab-sprite test could be implemented like:

fn main() {
    Test {
        label: "Test rendering a crab sprite".to_string(),
        setup: |app| {
            app.add_state::<AppState>()
                .add_system(spawn_sprite.in_schedule(OnEnter(AppState::InGame)));
        },
        setup_graphics: |app| {
            app.add_plugin(CrabGraphicsPlugin);
        },
    }
    .run();
}

Deployment

I will (probably) add instructions on deployment much later on in this series, but for now those interested should be well-supported by Bevy's existing resources. I highly recommend looking at bevy-shell-template if you haven't already.

Smaller Debugging Stuff

One generally useful package for development and testing is bevy-inspector-egui which spawns a useful terminal for interacting with your entities.

We can add it to our test struct to make sure it's always there when we are in a test scene.

use bevy_inspector_egui::quick::WorldInspectorPlugin;

impl<A> Test<A> {
    pub fn run(&self) {
        let app = App::new();
        // ...
        app.add_plugin(WorldInspectorPlugin::new());

For 2D, I also have used bevy_prototype_lyon as a way to draw "debug shapes" that outline things like transparent sprites or otherwise.

You can find more useful tools like schedule graph visualizers and more in the Bevy assets library.

Conclusion

There you have it! At this point, we can add assets to our build, we can set up test scenes, and we know how to add new system behavior. Try cloning the workbook repository, checking out the commit for this post, and running cargo test e2e-crab-sprite to make it work.

The next post will start implementing gameplay logic using these tools. See you there!

Continue reading