Introduction
This guide introduces developers to Bevy's ECS component, by porting the game written for Hands-on Rust: Effective Learning through 2D Game Development and Play to Bevy's ECS.
How to approach this book
The book, and the source project, are structured as a series of incremental steps, leading to a full game.
In the code repository, I've made minor cleanups and restructurings to the source project; the locations of the workspaces are:
The steps have been numbered (and have matching names in the source/port), in order to facilitate sequential examination and comparison; each step is an independent workspace.
This book presents multiple chapters for each step, explaining the concepts involved. When reading a chapter, one can compare the source step (workspace) with the ported one, and/or with the previous step in the respective project.
The starting chapter is 06.01, where an ECS is introduced in the design. Steps that don't introduce any new ECS concept, or don't significantly extend an introduced one, are skipped.
It's technically possible to read this book standalone, without the source one, but for newcomers to ECS, it's not advised, as in this context I'm not explaining the ECS concepts (only their Bevy API implementation).
References
The Bevy used version is 0.7.0; all the API links refer to the tag v0.7.0
in Bevy's repository.
Port style
Since comparison is a foundation of this book, I've maintained high and low-level structures as similar as I could; the most radical divergence is due to Bevy's state management. There are still a few differences, but they're minor.
Thanks
This project has been kindly sponsored by Ticketsolve; naturally, it wouldn't have been possible without the great work of Herbert Wolverson, and the Bevy community.
Introduction to step 06.01
In this chapter, the basic elements of the ECS system are introduced.
The directory name is 06_EntitiesComponentsAndSystems_01_playerecs
.
Base structure
The very first step in the port is plugging Bevy's ECS. The source project has a mixed, game engine-wise, design:
- the game loop is managed by the game engine (bracket-lib);
- for each cycle (frame), an update is invoked on the ECS (Legion).
This is bare-bones structure of the source project:
// source: main.rs
struct State {
ecs: World,
resources: Resources,
systems: Schedule,
}
impl State {
fn new() -> Self {
// [...]
Self {
ecs: World::default(),
resources: Resources::default(),
systems: build_scheduler(),
}
}
}
impl GameState for State {
fn tick(&mut self, ctx: &mut BTerm) {
// [...]
self.systems.execute(&mut self.ecs, &mut self.resources);
render_draw_buffer(ctx).expect("Render error");
}
}
fn main() -> BError {
// [...]
main_loop(context, State::new())
}
The translation is straightforward:
World
andResources
map toApp
(which also owns the resources)- Legion's systems update for each tick maps to the
App#update()
API.
leading to:
// port: main.rs
struct State {
ecs: App,
}
impl State {
fn new() -> Self {
// [...]
Self { ecs: App::new() }
}
}
impl GameState for State {
fn tick(&mut self, ctx: &mut BTerm) {
// [...]
self.ecs.update();
render_draw_buffer(ctx).expect("Render error");
}
}
World and systems
World
In Legion, we work directly on World
and Resources
:
// source: main.rs
let mut ecs = World::default();
let mut resources = Resources::default();
// [...]
self.systems.execute(&mut self.ecs, &mut self.resources);
In Bevy, the semantics are similar, although the idea is to use systems without interacting with the underlying data structures; this is because the engine manages them, and accessing them directly limits the (parallel) scheduling.
We still can manipulate World
, just as long as we're aware that accessing it mutably, prevents systems from running. In the ported project, in order to maintain the structure similar to the source, I've intentionally done it:
// port: spawner.rs
// The world instance is accessible via App#world.
//
world.spawn().insert_bundle((
Player,
PointC(pos),
Render {
color: ColorPair::new(WHITE, BLACK),
glyph: to_cp437('@'),
},
));
Generally speaking, while one can use this approach, in particular in stages where performance is not a concern, using systems should be preferred.
Systems
In Legion, systems require attributes, and queries are manually invoked:
// source: entity_render.rs
#[system]
#[read_component(Point)]
#[read_component(Render)]
pub fn entity_render(ecs: &SubWorld, #[resource] camera: &Camera) {
// ...
<(&Point, &Render)>::query()
.iter(ecs)
.for_each(|(pos, render)| {
draw_batch.set(*pos - offset, render.color, render.glyph);
});
// ...
}
In Bevy, systems are simpler; they don't require attributes, and the queries are declared as function parameters:
// port: entity_render.rs
pub fn entity_render(query: Query<(&PointC, &Render)>, camera: Res<Camera>) {
// ...
for (pos, render) in query.iter() {
draw_batch.set(pos.0 - offset, render.color, render.glyph);
}
// ...
}
In this example, we perform two accesses:
- we query entities, via
Query
type; in this case, entities withPointC
andRender
components - we retrieve a
Camera
resource, viaRes
(readonly access) type
Another fundamental API component is Commands
, which is used primarly to add/remove entities and resources.
The following example shows other functionalities:
// port: player_input.rs
pub fn player_input(
mut commands: Commands,
mut player_query: Query<&mut PointC, With<Player>>,
(map, key, mut camera): (Res<Map>, Option<Res<VirtualKeyCode>>, ResMut<Camera>),
) {
// ...
}
There are a few new concepts here (besides Commands
):
With
: query entities with a certain component, without retrieving itResMut
: mutably access a resourceOption<Res>
: access a resource that may not be existing
Note how Option<Res<T>>
is semantically different from Res<Option<T>>
:
Option<Res<T>>
is a resource of typeT
that may not be storedRes<Option<T>>
is a resource of type<Option<T>>
that is assumed to be stored
if a resource is not stored, the system will panic in the second case, but not the first.
Querying will be explored more in detail in the next chapters.
Components, entities and Resources
Components
In Bevy, components must derive Component
:
// port: component.rs
#[derive(Component)]
pub struct Render {
pub color: ColorPair,
pub glyph: FontCharType,
}
#[derive(Component)]
pub struct Player;
#[derive(Component)]
pub struct PointC(pub Point);
There's something interesting here: PointC
, which is a tuple struct, unlike the other (regular) structs.
Why so? Because the type Point
is not defined in our crate, and therefore, can't derive Component
; in order to work this problem around, we wrap it in our type (PointC
), and derive the wrapping type.
Entities
Adding entities to the ECS is trivial both in Legion and Bevy.
The simplest possible form that we can adopt to create an entity (in Bevy lexicon, a "Bundle"), is to insert a tuple:
// port: spawner.rs
// Note that we generally use systems to manage entities, but in this case, we're strictly following
// the source project's design.
//
pub fn spawn_player(world: &mut World, pos: Point) {
world.spawn().insert_bundle((
Player,
PointC(pos),
Render {
color: ColorPair::new(WHITE, BLACK),
glyph: to_cp437('@'),
},
));
}
The entity inserted above has three components (Player
, PointC
and Render
), which are bundled together as tuple.
We can use a more declarative approach, and use an annotated struct:
#[derive(Bundle)]
struct PlayerBundle {
player: Player,
pos: PointC,
render: Render,
}
It's crucial, in this case, to annotate the struct with #[derive(Bundle)]
; if one forgets this, and accidentally uses a bundle as component, no error will be raised!
Bundles can be nested (and again, we need to annotate the children, this time with #[bundle]
), although in this project, this feature is not used:
#[derive(Bundle)]
struct PlayerBundle {
player: Player,
pos: PointC,
#[bundle]
sprite: SpriteSheetBundle,
}
Resources
Resources, differently from components, currently (this may change in the future!) don't need to be derived; anything inserted in Bevy's ECS is a resource.
// port: main.rs
// key is a bracket-lib `VirtualKeyCode` type
// ecs is the Bevy `App` instance
//
if let Some(key) = ctx.key {
self.ecs.insert_resource(key);
} else {
self.ecs.world.remove_resource::<VirtualKeyCode>();
}
Since there can be only one resource for a given type, when a resource is inserted, any existing resource with the same type is going to be overwritten.
Note how in the example above, we remove a resource through World
; while this is acceptable, we should strive to use systems where possible. The technical reason for this usage is that Bevy provides resource insertion via the App
instance, but not the removal. This makes sense architecturally, since in the initialization of a project, one insert resources rather than removing them.
Later, we'll see how to access resources in systems.
Keyboard input
In Bevy, keyboard input handling is performed via resources:
fn keyboard_input(keys: Res<Input<KeyCode>>) {
if keys.just_pressed(KeyCode::W) { /* ... */ }
// ...
}
In this context, we rely on bracket-lib for input handling, so the API above is not used.
The only thing that needs to be kept in mind is that, in the context of the port, we need to remove the key resource:
// port: main.rs
// key is a bracket-lib `VirtualKeyCode` type
//
if let Some(key) = ctx.key {
self.ecs.insert_resource(key);
} else {
self.ecs.world.remove_resource::<VirtualKeyCode>();
}
If we forget the remove_resource
invocation, if the player doesn't press any key, the current frame will still hold the keypress resource from the previous frame!
This is not needed when using Bevy input handling (with an exception: see here); it's only necessary in the context of the port.
Introduction to System sets
One fundamental different between the architectures of the source design (Legion) and the port (Bevy) is how systems are grouped and scheduled.
In Legion, this is very simple; all the systems that belong to a (conceptual) state, are grouped together into a a "Schedule", which is sent to Legion, to be run on each update:
// source: mod.rs
Schedule::builder()
.add_system(player_input::player_input_system())
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.build()
// source: main.rs
// `self.systems` is the Schedule instance built above.
//
self.systems.execute(&mut self.ecs, &mut self.resources);
Bevy uses similar semantics as well, however, they are more conceptually more complex; the closest concept in Bevy is the system of Stage
s and SystemSet
s. In this chapter, we'll explore the SystemSet
s.
Warning
As of v0.7, Bevy's states are practically unusable, except for trivial programs; this is due to the internal APIs used to implement them (see here). Trying, for example, to use the standard SystemSet
/Stage
Bevy APIs for this project, will cause the program to hang.
Fortunately, there is a third party crate, iyes_loopless, that solves this problem, while also maintaining the semantics identical (or very close) to Bevy's, with a clean interface.
The rest of this book assumes that the iyes_loopless
crate is used:
# Cargo.toml
[dependencies]
bevy = "0.7.0"
iyes_loopless = "0.5.1"
SystemSet
s
A SystemSet
in Bevy, is the abstraction that allows grouping and scheduling the systems, based on user-provided properties.
At this step, only the grouping functionality is used:
// port: mod.rs
pub fn build_system_set() -> SystemSet {
SystemSet::new()
.with_system(player_input::player_input)
.with_system(map_render::map_render)
.with_system(entity_render::entity_render)
}
The above is the simplest form that a system set can have: an anonymous group of systems that will run in parallel.
Don't forget the parallel - by default, Bevy runs the systems in parallel, so consider this when designing the system.
Another extremely important concept is that changes applied by a system to entities, are not seen by systems in the same system set. This doesn't matter now (the renderings are independent of the player entity properties), so we'll get back on this later.
In the most basic form, system sets are registered with Bevy directly on the App
instance:
// port: main.rs
ecs.add_system_set(build_system_set());
Bevy provides APIs to organize system sets when more are added; at this stage, since there's only one, we register it directly without setting any property.
Query basics
Queries are a shiny example of Bevy's capabilities. Putting aside performance, any project will certainly benefit from their ergonomy.
Their distinctive trait is that they're entirely, and automatically, defined by the systems function signature, while allowing at the same time a great amount of flxeibility.
Basic queries definition and iteration
This is a basic (edited) example:
// port: entity_render.rs
pub fn entity_render(query: Query<(&PointC, Option<&Render>)>) {
for (pos: &PointC, render: Option<&Render>) in query.iter() {
if let Some(render) = render {
// ...
}
}
}
Here, we query all the entities that have a PointC
, and optionally a Render
component, and retrieve both of them in the query.
Iteration can be mutable or immutable; in order to make the above mutable:
- make
query
mutable:mut Query<(&PointC, Option<&Render>)>
- use the mutable iterator API:
query.iter_mut()
Resources and commands
In addition to Queries, systems are also typically provided access to commands and resources:
// port: player_input.rs
pub fn player_input(
mut commands: Commands,
mut player_query: Query<&mut PointC, With<Player>>, //(1) (2)
(map, key, mut camera): (Res<Map>, Option<Res<VirtualKeyCode>>, ResMut<Camera>),
)
Commands are used to perform write operations, typically on entities and resources; while the details will be described later, in this context, an example invocation is:
// port: player_input.rs
commands.remove_resource::<VirtualKeyCode>();
which removes a resource from the storage.
We can observe resources access on the third set of parameters; its definition is intuitive, and the main information we need to know is:
- if a resource may not exist, it must be accessed via
Option<Res<T>>
; - immutable/mutable access to a resource are performed via, respectively, the types
Res
/ResMut
.
Queries resources implement Deref
/DerefMut
, so we can access them without any syntactic requirement, for example:
// port: player_input.rs
camera.on_player_move(destination);
which is invoking the method:
// port: camera.rs
impl Camera {
pub fn on_player_move(&mut self, player_position: Point) { /* ... */ }
}
since access to ResMut
automatically dereferences to &mut Camera
.
Query conditionals
By using components directly as query parameters, we've made an implicit assumption: that we want to search entities that include all the components specified, and that we want to retrieve all the components.
A companion concept one may want to express is exclusion: query entities that do not include a component; we use the With
trait for this:
// port: entity_render.rs
pub fn entity_render(query: Query<&PointC, Without<Invisibility>>) {
for pos: &PointC in query.iter() {
// ...
}
}
in this case we want all the entities that have a PointC
component, but not an Invisibility
one.
There's also something worth mentioning: when querying a single component, we don't use a tuple as Query
type parameter Query<(&Player), ...>
, but the type alone; this has an effect on the iteration, which is pos: &PointC
instead of (pos: &PointC)
.
The port does not make use of the Without
conditionals, however, there is a link: the inclusion conditional - With
.
What's the use, if Query
implicitly perform inclusion? Convenience, and potentially performance. In some cases, we query for a component, but we don't need to actively use it; by specifying it as With
parameter, it's not retrieved; an (edited) example is:
// port: player_input.rs
pub fn player_input(mut player_query: Query<&mut PointC, With<Player>>) {
if let Ok(pos) = player_query.get_single() {
camera.on_player_move(pos);
}
}
In this case we want to know the position of the player, in order to process it, but since we don't process the Player component, we don't retrieve it.
There's a new API used here: get_single()
; it is part of a family of APIs that allow access to a set of components in a query, without iteration:
API | Checked? (Ret. type) | Access |
---|---|---|
single() | N (T ) | immutable |
single_mut() | N (T ) | mutable |
get_single() | Y (Result<T> ) | immutable |
get_single_mut() | Y (Result<T> ) | mutable |
(note that the return type is simplified)
The APIs are easy to remember:
- the
get_
prefix specifies checked access (returnsNone
if an instance is not found, instead of panicking), similarly toHashMap
; - the
_mut
suffix specifies mutable access.
Introduction to step 06.02
In this chapter, we're going to explore some other Query
ing concepts.
The directory name is 06_EntitiesComponentsAndSystems_02_dungeonecs
.
Querying: Entities and Param sets
Querying entities
In the previous chapter about queries, we've queried, and acted upon, only components. How about we need to act entities?
Both Legion and Bevy use a very similar interface - simply, by adding Entity
to the query interface.
This is a query example:
// port: collisions.rs (edited)
pub fn collisions(
mut commands: Commands,
enemies_query: Query<(Entity, &PointC), With<Enemy>>,
) {
for (entity, pos) in enemies_query.iter() {
// ... act on the entity ...
}
}
The type Entity
is a classic example of handle; it's a lightweight type that supports common operations (copy, comparison, hash...), and therefore, can be conveniently passed around.
Expanding the example above, we can observe how we actually use it:
// port: collisions.rs
pub fn collisions(
mut commands: Commands,
player_query: Query<&PointC, With<Player>>,
enemies_query: Query<(Entity, &PointC), With<Enemy>>,
) {
let player_pos = player_query.single().0;
for (entity, pos) in enemies_query.iter() {
if pos.0 == player_pos {
commands.entity(entity).despawn()
}
}
}
specifically, we pass it to the entity despawning API, when certain component conditions are met (enemy pos
matching the player pos
).
Watch out! As we'll see later, despawning doesn't have an immediate effect.
Param sets
Param sets are a functionality that is not needed in the port, but it's important to know nonetheless.
Let's say, for the sake of example, that we have the following system:
// port: collisions.rs (edited)
pub fn collisions(
player_query: Query<&PointC, With<Player>>,
mut enemies_query: Query<&mut PointC, With<Enemy>>,
) {
let player_pos = player_query.single().0;
for mut pos in enemies_query.iter_mut() {
if pos.0 == player_pos {
pos.0.x = 0;
pos.0.y = 0;
}
}
}
which model a logic where instead of despawning the enemy entities, we move them away (by changing pos).
The project compiles! But, surprise surprise... once when running, we get a crash:
thread 'main' panicked at 'error[B0001]: Query<(Entity, &mut PointC), With<Enemy>>
in system collisions accesses component(s) PointC in a way that conflicts with a
previous system parameter. Consider using `Without<T>` to create disjoint Queries
or merging conflicting Queries into a `ParamSet`.', /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:173:5
stack backtrace:
0: rust_begin_unwind
at /rustc/4cbaac699c14b7ac7cc80e54823b2ef6afeb64af/library/std/src/panicking.rs:584:5
1: core::panicking::panic_fmt
at /rustc/4cbaac699c14b7ac7cc80e54823b2ef6afeb64af/library/core/src/panicking.rs:142:14
2: bevy_ecs::system::system_param::assert_component_access_compatibility
at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:173:5
3: <bevy_ecs::query::state::QueryState<Q,F> as bevy_ecs::system::system_param::SystemParamState>::init
at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:113:9
4: <(P0,P1,P2) as bevy_ecs::system::system_param::SystemParamState>::init
at /home/user/.cargo/registry/src/github.com-1ecc6299db9ec823/bevy_ecs-0.7.0/src/system/system_param.rs:1247:21
This is interesting; we're prevented from query PointC
in two queries, with incompatible access mode. Bevy doesn't enforce incompatible access compile-time, but it does enforce it runtime.
In order to solve this problem, one of the two solutions referenced is to use ParamSet
:
pub fn collisions(
mut queries: ParamSet<(
Query<&PointC, With<Player>>,
Query<&mut PointC, With<Enemy>>,
)>,
) {
let player_pos = queries.p0().single().0;
for mut pos in queries.p1().iter_mut() {
if pos.0 == player_pos {
pos.0.x = 0;
pos.0.y = 0;
}
}
}
There's no significant difference; we just group the queries inside a ParamSet
, and access them via pN()
functions. Considering the constraint, this is impressively ergonomic!
Introduction to steps 07.01-02
This chapter covers both steps 07.01 and 07.02; we're going to explore State
s, SystemSet
s and Stage
s (and the iyes_loopless
crate).
The Bevy concepts described here are the foundation for structuring games, and this step is the one that departs the most from the source project.
The directory names are 07_TurnBasedGames_01_wandering
and 07_TurnBasedGames_02_turnbased
.
A look at the states model in the source project
Source structure, and Bevy's Stage
s
Before getting technical, we need to have a look at how the source project models the states:
// source: mod.rs (edited)
pub fn build_input_scheduler() -> Schedule {
Schedule::builder()
.add_system(player_input::player_input_system())
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
}
pub fn build_player_scheduler() -> Schedule {
Schedule::builder()
.add_system(collisions::collisions_system())
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.add_system(end_turn::end_turn_system())
}
pub fn build_monster_scheduler() -> Schedule {
Schedule::builder()
.add_system(random_move::random_move_system())
.add_system(collisions::collisions_system())
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.add_system(end_turn::end_turn_system())
}
We observe that there are three states:
- player input
- collision handling (following player move)
- monster moving, and collision handling
Every state performs rendering; state change is performed in two different ways:
- on the player input state, performed by the
player_input
system, when the user presses a key; - on the other states, performed by an adhoc system.
As previously mentioned, Bevy does have a concept semantically similar to Legion's Schedule
, however, it's more complicated, and it's better/closer modeled by a set of other types.
Commands flushing and Stage
s modeling
In the source schedulers, there's something interesting - the flush()
commands:
// source: mod.rs
pub fn build_monster_scheduler() -> Schedule {
Schedule::builder()
.add_system(random_move::random_move_system())
.flush()
.add_system(collisions::collisions_system())
.flush()
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.add_system(end_turn::end_turn_system())
.build()
}
This is a crucial concept to understand!
When ECSs like Legion or Bevy modify entities/components, they don't commit the changes immediately; if one accidentally forgets flushing, it will lead to a very confusing situation, because systems won't find the expected data.
Compared to (current) Bevy, Legion has an advantage - as seen above, Legion has an API for flushing at will, while Bevy doesn't (as of Jun/2022, there's an open issue).
Fortunately, we can easily model this behavior using Stage
s - when a Stage
is completed, the changes are committed.
Bevy's model has a disadvantage, though: we'll need to model more states than we do in Legion. In the source project, there are three schedulers, and that's all; in Bevy, we'll need:
- three states, one for each Legion scheduler
- seven stages, one for each commands transaction
This is not dramatic, but there is certainly an increase of the complexity that we'll need to handle; fortunately, in our case, we can reduce the number of stages.
A basic states model in Bevy
States modeling problems in Bevy v0.7
Prior to any discussion, it must be pointed out that Bevy's state modeling as a whole, as of v0.7, it's broken: using the related APIs to their full extent in a project, will cause breakages (e.g. Bevy will hang); a typical example is that one can't use FixedTimeStep
s and State
s at the same time, or, in the case of this project, State
s with Stage
s.
Fortunately, a 3rd party developer has created a plugin that solves this problem: iyes_loopless
. In order to proceed with the next steps, it's therefore necessary to add it to the Cargo configuration.
As of June 2022, it's planned for future Bevy versions to integrate the iyes_loopless
logic in Bevy.
First concept: Stage
s
In the previous chapter, we've seen the source project's states model. In order to translate it in Bevy, we'll use three major concepts; the first one is Stage
s.
On each frame, Bevy's scheduler goes through a predefined set of so-called Stage
s, which are essentially group of operations performed on a schedule; they are the appropriate abstraction to use (for reasons we'll see in the following chapters) to model states.
By default, Bevy has several stages, however, we're interested only in one: the Update
stage; the reason is that during the other stages, other operations are performed behind the scenes, and especially in a simple project, one doesn't want to touch them.
In order to accommodate the states we need, we can add new stages - the idea is that we schedule them to be run in the order we want, but always after Update
and before the stage following it by default (called PostUpdate
).
Designing the states
Now that we've examined all the concepts involved, let's design the Bevy's states.
We'll use the following code as reference, which is the source project as of this step:
// source: mod.rs
pub fn build_input_scheduler() -> Schedule {
Schedule::builder()
.add_system(player_input::player_input_system())
.flush()
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.build()
}
pub fn build_player_scheduler() -> Schedule {
Schedule::builder()
.add_system(collisions::collisions_system())
.flush()
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.add_system(end_turn::end_turn_system())
.build()
}
pub fn build_monster_scheduler() -> Schedule {
Schedule::builder()
.add_system(random_move::random_move_system())
.flush()
.add_system(collisions::collisions_system())
.flush()
.add_system(map_render::map_render_system())
.add_system(entity_render::entity_render_system())
.add_system(end_turn::end_turn_system())
.build()
}
A first, direct translation
In a direct transation, we create one stage for each transaction:
stage description | source scheduler number | stage count |
---|---|---|
get player input | 1 | 1 |
render player input | 1 | 2 |
handle player collisions | 2 | 3 |
render player collisions | 2 | 4 |
move monsters | 3 | 5 |
handle monster collisions | 3 | 6 |
render move monsters | 3 | 7 |
All good! This can work, and it's a very straight model. Something important to keep in mind is that, in the game design, if the player doesn't press any key, the subsequent stages won't be executed!
Simplifying the source project states
Now, let's look at the rendering systems: map_render::map_render_system()
and entity_render::entity_render_system()
; we notice two things:
- they are common to all the schedulers;
- they need to be run in their separate transaction, otherwise, they may render a partial state (this would be the equivalent of the "read uncommitted" transaction level in database systems).
However, if we consider that this all happens over a single frame, we now see an opportunity for simplification: we just need to render only once! Let's have a look at the new table:
stage description | source scheduler number | stage count |
---|---|---|
get player input | 1 | 1 |
handle player collisions | 2 | 2 |
move monsters | 3 | 3 |
handle monster collisions | 3 | 4 |
render | 4 | 5 |
We need a small modification here ๐ If the player doesn't send any input, the rendering won't be executed. Fortunately, we can shift the render:
stage description | source scheduler number | stage count |
---|---|---|
render | 4 | 1 |
get player input | 1 | 2 |
handle player collisions | 2 | 3 |
move monsters | 3 | 4 |
handle monster collisions | 3 | 5 |
Excellent. From the user perspective, if we render at the beginning of a frame, or at the end of the previous one, it makes no difference - except that, with the former approach, we've solved the rendering problem.
We can go further! we don't strictly need to sequentially render and get the player input in a sequence; this is a slow game, and rendering a tiny fraction of second after won't make any difference; the new plan is therefore:
stage description | source scheduler number | stage count |
---|---|---|
render+get player input | 4+1 | 1 |
handle player collisions | 2 | 2 |
move monsters | 3 | 3 |
handle monster collisions | 3 | 4 |
Notice that when we talk about a tiny fraction of second, it not one frame of delay; the input/rendering systems are rendered in parallel (as opposed to in a sequence), so there is virtually zero delay.
Summary of the design
With the design above, we now have 4 stages:
- render+get player input
- handle player collisions
- move monsters
- handle monster collisions
We'll also keep the 3 game states of the source project:
- awaiting input
- player turn
- monster turn
Remember: states are used to represent high-level game states, while stages are subdivisions of the states, for transactional purposes.
Implementing the states in Bevy
In this chapter, we'll use the iyes_loopless
Bevy crate!
Adding the stages
First, let's define the stages:
// Port: game_stage.rs
#[derive(Debug, Clone, Eq, PartialEq, Hash, StageLabel)]
pub enum GameStage {
MovePlayer,
MoveMonsters,
MonsterCollisions,
}
Is there one missing? No! For simplicity, we're going to use Bevy's standard one, Update
, for the rendering and player input. It's perfectly possible to leave Update
untouched, and add an extra state for those systems; it's up to the developer's taste.
Now, we'll need to register them; this is performed via some App
APIs, of which, for simplicity, we'll use just one, add_stage_after
:
// Port: main.rs
ecs.add_stage_after(CoreStage::Update, MovePlayer, SystemStage::parallel())
.add_stage_after(MovePlayer, MoveMonsters, SystemStage::parallel())
.add_stage_after(MoveMonsters, MonsterCollisions, SystemStage::parallel());
Note that we're specifying that we want to run the systems of each stage in parallel (the alternative is SystemStage::single_threaded()
), taking advantage of Bevy's ECS.
Adding the states
Now, let's define the states, which are encoded using a standard Bevy resource:
// Port: turn_state.rs
#[derive(Debug, Clone, Eq, PartialEq, Hash)]
pub enum TurnState {
AwaitingInput,
PlayerTurn,
MonsterTurn,
}
We set the initial state by adding the resource to the ECS:
// Port: main.rs
ecs.insert_resource(TurnState::AwaitingInput);
In the next section, we'll encode the conditions.
Setting the SystemSet
s
Now we can add the SystemSet
s.
// Port: mod.rs (edited)
pub fn build_system_sets(app: &mut App) {
app.add_system_set(
SystemSet::new()
.with_system(map_render::map_render)
.with_system(entity_render::entity_render),
);
app.add_system(
player_input::player_input
.run_if_resource_equals(TurnState::AwaitingInput)
);
app.add_system_set_to_stage(
GameStage::MovePlayer,
ConditionSet::new()
.run_if_resource_equals(TurnState::PlayerTurn)
.with_system(collisions::collisions)
.with_system(end_turn::end_turn)
.into(),
);
app.add_system_set_to_stage(
GameStage::MoveMonsters,
ConditionSet::new()
.run_if_resource_equals(TurnState::MonsterTurn)
.with_system(random_move::random_move)
.into(),
);
app.add_system_set_to_stage(
GameStage::MonsterCollisions,
ConditionSet::new()
.run_if_resource_equals(TurnState::MonsterTurn)
.with_system(collisions::collisions)
.with_system(end_turn::end_turn)
.into(),
);
}
The state handling is done; let's review it.
First, rendering:
app.add_system_set(
SystemSet::new()
.with_system(map_render::map_render)
.with_system(entity_render::entity_render),
);
By not specifying the stage, we're adding those systems to the Update
stage. We're also not specifying a state, as we don't strictly need it; we could bind it to the AwaitingInput
state, but conceptually speaking, there isn't such connection.
Note how there is no temporal dependency between the two rendering systems (therefore, they will run in parallel); since they can execute independently (they draw to two different planes), we take the chance to parallelize them.
Now, the player input:
app.add_system(
player_input::player_input
.run_if_resource_equals(TurnState::AwaitingInput)
);
This also goes in Update
, however, we slot it in the associated game state (AwaitingInput
). Note how we encode the conditional using the run_if_resource_equals()
iyes_loopless
API; it allows a system/set to run if the ECS includes an enum resource whose variant is the one specified.
It's extremely important not to use the iyes_loopless
state management API (run_in_state()
) for this purpose! While both are backed by resources, the difference is that the decision whether to run each system/set, is taken:
- with the
run_in_state()
condition, once per frame, at the beginning of it; - with the
run_if_resource_equals()
condition, at the beginning of each stage.
The consequence is that if we set a new state (by updating the resource) in a given stage, systems in subsequent stages with conditions encoded via run_in_state()
, will not run, even if they match the new state, because the decision not to run them was taken at the beginning of the frame!
Now, the player move:
app.add_system_set_to_stage(
GameStage::MovePlayer,
ConditionSet::new()
.run_if_resource_equals(TurnState::PlayerTurn)
.with_system(collisions::collisions)
.with_system(end_turn::end_turn)
.into(),
);
There are a few notable things here. First, we need to invoke ConditionSet#into()
- this is because such type belongs to the iyes_loopless
crate, and it needs to be converted to make it compatible with Bevy's add_system_set_to_stage
APIs.
Then, we're now adding the system set to a stage (MovePlayer
).
Like for rendering, we run the systems in parallel. Since we are sure that Bevy will wait for both systems to complete before moving to the next stage (MoveMonsters
), there's no risk of a race condition.
The rest of the systems follow the same structure.
A small detail not to forget is turn state change is performed by the state machine system end_turn
, but not in the AwaitingInput
state - in this case, it's performed by the player_input
system (since it depends on the user input):
// Port: player_input.rs (extract)
commands.insert_resource(TurnState::PlayerTurn);
as you can see, changing state is just a matter of updating the state resource.
Introduction to step 07.03
In this concept we're going to explore event passing.
The directory name is 07_TurnBasedGames_03_intent
.
Managed event passing
This chapter presents an interesting departure from the source project! We're going to implement event passing; this is interesting because it's possible to observe the improvements offered compared to manual event handling (which is the implementation chosen in the source project).
A look at a manual implementation
Let's see what the source project implementation. Since the messages are regular entities, first, they need to be declared:
// Source: component.rs
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct WantsToMove {
pub entity: Entity,
pub destination: Point,
}
Now, they're simply added to the world:
// Source: player_input.rs (edited)
pub fn player_input(
commands: &mut CommandBuffer,
) {
// ...
// Legionโs push function doesnโt work with single-component insertions
commands.push((
(),
WantsToMove {
entity: *player_entity,
destination: player_destination,
},
));
// ...
}
Here we observe a small limitation of Legion, in particular, when used for events - it doesn't accept insertion of individual components, so we need to add a phony one (the empty tuple ()
).
Reading is performed via a standard query:
// Source: movement.rs (extract)
pub fn movement(
entity: &Entity,
want_move: &WantsToMove,
// ...
commands: &mut CommandBuffer,
) {
if map.can_enter_tile(want_move.destination) {
// ... operate on `want_move`
}
commands.remove(*entity);
}
Note how we have to manually remove the pseudo-message entity.
That's all. This implementation is very simple, which works well in the design of this game.
Bevy's Event passing
Bevy's managed event passing is equally simple. We define the event type, and we register it:
// Port: events.rs
pub struct WantsToMove {
pub entity: Entity,
pub destination: Point,
}
// Port: main.rs
ecs.add_event::<WantsToMove>();
Note that differently from entities, message types don't need to be derived as components; in the port, this gives a minor simplification - we don't need to use the wrapper component PointC
- we use just Point
.
Now, let's send the events:
// Port: player_input.rs (edit)
pub fn player_input(
mut move_events: EventWriter<WantsToMove>,
) {
// ... compute the player destination ...
move_events.send(WantsToMove {
entity: *player_entity,
destination: player_destination,
});
// ...
}
and read them:
// Port: movement.rs (edit)
pub fn movement(
mut move_events: EventReader<WantsToMove>,
// ...
) {
for &WantsToMove {
entity,
destination,
} in move_events.iter()
{
if map.can_enter_tile(destination) {
// ... operate on entity/destination
}
}
}
Very straightforward. There is a very important difference of the port: we don't need to remove the message; this is taken care of by Bevy.
Systems ordering
There are two crucial concepts to be aware of, when using Bevy's events:
- events persist for at most two frames - the frame where the event is sent, and the next;
- the developer must take care of the systems ordering, in order to read the events as soon as possible.
In some games, lag is not a problem, however, in some others, if the systems ordering is improperly designed, event reading may be lag, and the lag can propagate to other systems/frames.
It's therefore useful to review how system ordering is designed in the port, at this stage:
// Port: mod.rs (extract)
// Here we write the event; this is the default (Update) stage.
//
app.add_system(
player_input::player_input.run_in_state(AwaitingInput)
);
// Here we read the events; the stage is the next, which guarantees the ordering.
//
app.add_system_set_to_stage(
MovePlayer,
ConditionSet::new()
.run_in_state(PlayerTurn)
.with_system(movement::movement) // reads the event
.into(),
);
We don't need to introduce any syncing (ie. extra stages) - the project's states and actions are well-defined, so the sync points (implemented via stages) fit cleanly and, in this case, for free.
Considerations
Whether to use or not managed event passing, is a valid question; after all, one can just write entities, send them around, then remove them. In the context of this project, the advantages that managed events passing gives are:
- it avoids manual removal of the messages;
- the implementation is semantically more consistent with its intent;
- as a consequence of the previous point, workarounds are not necessary.
These don't constitute a very significant improvement, however, given the simplicity of Bevy's API, in my opinion, using it is a no-brainer.
One larger projects, when there are multiple readers, the advantage of not having to manually clear the events, is definitely more significant.
Bug in the port project
There is a bug in the design of the port project ๐ : events are processed twice, since the event reading systems are scheduled twice.
See tracking issue for the details (including fix); contributions are welcome ๐
Introduction to step 09.02
Before and after this step, there aren't - from an ECS perspective - new concepts, so this is the last chapter; I'm going to explain a few concepts related to Bevy's World
.
The directory name is 09_WinningAndLosing_02_losing
.
Bevy's World
World
was briefly mentioned in the World and systems chapter. In general, there are two ways of accessing the World
instance: from the App
instance, and from a system.
We've seen the first case:
// port: app.rs
let mut ecs = App::new();
// ...
spawn_player(&mut ecs.world, map_builder.player_start);
// port: spawner.rs
// The world instance is accessible via App#world.
//
world.spawn().insert_bundle((
Player,
PointC(pos),
Render {
color: ColorPair::new(WHITE, BLACK),
glyph: to_cp437('@'),
},
));
There's nothing notable in it; we're at the initialization state of the application, so we don't have specific concerns.
In order to get access to World
from a system, we just specify it as parameter:
pub fn world_read_system(world: &World) {
if let Some(player) = world.entity(entity).get::<Player>() {
// ...
}
}
Exclusive systems
In the case of mutable World
access, there are significant constraints.
First, we need to mark the system as "exclusive" when adding it:
pub fn world_write_system(world: &mut World) {
if let Some(player) = world.entity(entity).get_mut::<Player>() {
// ...
}
}
app.add_system(world_write_system.exclusive_system())
Second: mutable access to World
is incompatible with other queries, for obvious reasons:
// This is not allowed!
//
pub fn not_working_exclusive_system(
world: &mut World,
timer: ResMut<MyTimer>,
) {
// in theory, here, we we'd have aliasing of the MyTimer resource
}
Finally, and very importantly, exclusive systems can't be scheduled concurrently with other systems, potentially causing bottlenecks!
Clearing the world
In some cases, we want to clear the entities in the world, for example, in order to partially perform a state cleanup (don't forget the resources!); this is easily accomplished:
world.clear_entities();
As of Jun/2022, there is no way to clear resources; however, while there's a PR open, it must be very clear that one can't just clear all the resources, since Bevy uses resources for various tasks - fully clearing the world would likely bring Bevy to an unusable state.
Conclusion
In this mini-book, we've explored how Bevy's ECS applies to a full-fledged, nontrivial, game.
Before reading Hands-on Rust, ECSs were a bit of mystery to me. After the book, basing a game on an ECS felt natural and advantageous. Nowadays, I think that a game does not need to be large in order to - overall - profit from an ECS.
I'm impressed by Bevy's ECS interface, as the querying system is very coincise, and makes for readable code. On the other hand, Bevy has a fundamental, unaddressed, problem in the state managament, however, the iyes_loopless
solves it cleanly, and one can produce solid work with this engine.
Happy experimentation ๐
Exercise for the reader
I think that fully practicing the concepts studied on a book, brings understanding to another level. This book presents a fantastic opportunity!
Exercise
The source project has an uneven design. While the game itself is designed very neatly, its initialization is inconsistent with the rest, as it has a procedural design:
- it access the
World
directly, and doesn't use systems; - it uses (local) variables, and reinstantiates them, instead of using resources.
The exercise is to redesign the game setup, so that it fully applies the ECS design, by using systems and resources. If possible, don't reinitialize resources - reuse them!
In my opinion, performing this exercise will give the reader a more solid understanding of how to work with ECSs and design around them!