Rodney Lab
Posted on May 30, 2024
🐣 Using Bevy Entity Component System outside of Bevy
Not every game needs an Entity Component System (ECS), but for this Macroquad Rapier ECS post, I was keen to see how you might add an ECS to a Macroquad game with Rapier physics.
In a previous post, I used the Shipyard ECS with Macroquad. I have been working through a build-your-own physics engine tutorial, which used the Bevy game engine. I loved the ergonomics of the embedded ECS, so thought, I would take it for a spin here. Rapier the Rust physics engine used here has a Bevy plugin, but I wanted to try using both Rapier and the stand-alone Bevy ECS outside of Bevy, just for the challenge.
🧱 Macroquad Rapier ECS: What I Built
I continued with the bubble simulation, used for a few recent posts. It had no ECS, dozens of entities and a few other features that made it interesting to try with an ECS data model. So that was my starting point.
The simulation releases bubbles which float to the top of the screen, where they are trapped by a Rapier height field. Once all existing bubbles are settled, the simulation spawns a fresh one.
The simulation uses a random number generator to decide on the initial velocity of spawned balls, and has running and paused states. The simulation triggers the paused state when the bubbles almost completely fill the window. Both the random number generator and the simulation state are singletons, and fit well into the ECS resource model. I also put the physics engine itself into an ECS resource. The C/C++ flecs ECS library, for example, calls resources singletons.
The Simulation within the ECS Model
As you would expect, each bubble is an entity. If you are new to ECSs you might imagine the ECS entities as rows of a database table. In this database world, the table columns are components. In my case, the balls all have the same components:
- a circle static mesh (which encodes colour and size data needed for rendering);
- a circle collider (for handling physical collisions with Rapier); and
- position and velocity components (used for updating the simulation physics).
Systems mutate the component properties, for example, there is a ball update system, which uses Rapier, to work out what the current velocity of the bubble is, at each step, then update the velocity component.
In the following sections, we look at some code snippets to help illuminate the points here, and hopefully give you an idea of how I put the simulation together, adding an ECS to Macroquad. The full code is on GitHub, and there is a link to it further down.
🧩 Components
I just mentioned that bubble entities each have a few components. Here is some example code for spawning a new ball using the simulation ECS:
let _ball_entity = world.spawn((
Position(new_ball_position),
CircleMesh {
colour: BALL_COLOURS[macroquad_rand::gen_range(0, BALL_COLOURS.len())],
radius: Length::new::<length::meter>(BALL_RADIUS),
},
CircleCollider {
radius: Length::new::<length::meter>(BALL_RADIUS),
physics_handle: None,
},
Velocity(new_ball_velocity),
));
The new entity gets a Position
, CircleMesh
, CircleCollider
and Velocity
. The number of components here is arbitrary, and not fixed (as it might be when calling a constructor using an object-oriented approach). This grants flexibility as you develop the simulation or game.
Also note, I follow a Rust convention in naming the _ball_entity
identifier. The initial underscore (_
) indicates the identifier is not used anywhere else in the scope. We do not need it later, since the systems used to query and mutate the component properties, operate on entities with specific sets of components (properties). We will see this more clearly later.
In an ECS model, the entity is not much more than an integer, which Bevy ECS can use as an ID.
Units of Measurement
In a previous post on adding units of measurement to a Rapier game, I introduced the length units used in the snippet above. This pattern of using units leveraging Rusts type system, sense-checking calculations and helping to avoid unit conversion errors.
🏷️ Macroquad Rapier ECS: Tags
The Position
and Velocity
components, mentioned before, are structs with associated data. You can also have ECS tags, which are akin to components without data. Rapier has a type of collider that just detects a collision, and beyond that does not interact with the physics system, these are sensors.
I used a Sensor
tag in the ECS for colliders that are sensors, which helps to separate them out when running systems. The only sensor in the simulation runs along the bottom of the window. Towards the end of the simulation, when the window is almost full, a newly spawned bubble will inevitably bounce off an existing one and collide with the floor sensor. I added a system to pause the simulation when this occurs, effectively ending the simulation.
#[derive(Component, Debug)]
pub struct CuboidCollider {
pub half_extents: Vector2<f32>,
pub position: Isometry<f32, Unit<Complex<f32>>, 2>,
pub translation: Vector2<f32>,
}
#[derive(Component, Debug)]
pub struct Sensor;
This, above, code snippet shows a CuboidCollider
(used for the floor sensor), which is a regular component and then the Sensor
tag. The code snippet, below, initializes the floor sensor:
pub fn spawn_ground_sensor_system(mut commands: Commands) {
let collider_half_thickness = Length::new::<length::meter>(0.05);
let window_width = Length::new::<pixel>(WINDOW_WIDTH);
let window_height = Length::new::<pixel>(WINDOW_HEIGHT);
commands.spawn((
CuboidCollider {
half_extents: vector![
0.5 * window_width.get::<length::meter>(),
collider_half_thickness.value
],
translation: vector![
0.0,
(-window_height - collider_half_thickness).get::<length::meter>()
],
position: Isometry2::identity(),
},
Sensor,
));
}
Note, the Sensor
tag is included. To spawn the wall colliders on either side of the window, a very similar block is used, only omitting the Sensor
tag. We will see how this can be used with systems next.
🎺 Systems
Systems are code blocks, which are only run on components belonging to a specified component set. As an example, here is the system for updating bubble position and velocity within the game loop:
pub fn update_balls_system(
mut query: Query<(&mut Position, &mut Velocity, &CircleCollider)>,
physics_engine: Res<PhysicsEngine>,
) {
for (mut position, mut velocity, circle_collider) in &mut query {
if let Some(handle) = circle_collider.physics_handle {
let ball_body = &physics_engine.rigid_body_set[handle];
position.0 = vector![
Length::new::<length::meter>(ball_body.translation().x),
Length::new::<length::meter>(ball_body.translation().y)
];
velocity.0 = vector![
VelocityUnit::new::<velocity::meter_per_second>(ball_body.linvel().x),
VelocityUnit::new::<velocity::meter_per_second>(ball_body.linvel().y)
];
}
}
}
This system runs on any entity that satisfies the query of having Position
, Velocity
and CircleCollider
components. We can be more prescriptive, choosing entities that have a set of components, and also, either do, or do not have some other component or tag. We use With
when creating the floor sensor during the simulation initialization:
pub fn create_cuboid_sensors_system(
query: Query<&CuboidCollider, With<Sensor>>,
mut physics_engine: ResMut<PhysicsEngine>,
) {
for collider in query.iter() {
let new_collider =
ColliderBuilder::cuboid(collider.half_extents.x, collider.half_extents.y)
.position(collider.position)
.translation(collider.translation)
.sensor(true)
.build();
physics_engine.collider_set.insert(new_collider);
}
}
As you might expect, the equivalent code for creating the side wall colliders uses Without
in its query, and omits .sensor(true)
in its Rapier setup code.
📆 Schedules
We use ECS schedules to determine when systems run. The simulation has:
- a setup schedule, run once during simulation setup,
- a running schedule, executed on every run through the game loop in simulate/running mode; and
- a paused schedule, runs when we have paused the simulation.
Bevy ECS organizes the systems above into these schedules, which can include constraints to ensure systems run in the right order.
Here is the paused schedule code:
let mut paused_schedule = Schedule::default();
paused_schedule.add_systems(draw_balls_system);
This just needs to draw the balls (the screen is cleared in each game loop iteration, even while the simulation is paused). The running schedule features more systems.
That code above is triggered in the game loop, while the simulation state is set to paused:
loop {
// TRUNCATED...
clear_background(GUNMETAL);
let simulation_state = world
.get_resource::<SimulationState>()
.expect("Expected simulation state to have been initialised");
match &simulation_state.mode {
SimulationMode::Running => {
playing_schedule.run(&mut world);
}
SimulationMode::Paused => paused_schedule.run(&mut world),
}
next_frame().await;
}
That’s it! We covered all the major constituents of an ECS!
🙌🏽 Macroquad Rapier ECS: Wrapping Up
In this post on Macroquad Rapier ECS, we got an introduction to working with an ECS in Macroquad. In particular, we saw:
- the main constituents of an ECS including resources, schedules and tags, as well as entities, components, and systems;
- why you might want to add tags, and how you can use them in Bevy ECS system queries along with
With
andWithout
; and - ECS schedules for different game or simulation states.
I hope you found this useful. As promised, you can get the full project code on the Rodney Lab GitHub repo. I would love to hear from you, if you are also new to Rust game development. Do you have alternative resources you found useful? How will you use this code in your own projects?
🙏🏽 Macroquad Rapier ECS: Feedback
If you have found this post useful, see links below for further related content on this site. Let me know if there are any ways I can improve on it. I hope you will use the code or starter in your own projects. Be sure to share your work on X, giving me a mention, so I can see what you did. Finally, be sure to let me know ideas for other short videos you would like to see. Read on to find ways to get in touch, further below. If you have found this post useful, even though you can only afford even a tiny contribution, please consider supporting me through Buy me a Coffee.
Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on X (previously Twitter) and also, join the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Game Dev as well as Rust and C++ (among other topics). Also, subscribe to the newsletter to keep up-to-date with our latest projects.
Posted on May 30, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.