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
andResMut
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
SystemParam
s, which usually offer protected access to some global resource (and in many cases are useful APIs wrappingResource
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 wholeWorld
, 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 Plugin
s 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!