Bevy Tutorial: Pong
The goal of this tutorial is to build Pong using a minimal amount of dependencies for the purpose of learning Bevy.
This tutorial assumes you are complete beginner with only some basic Rust experience.
You can find the full source code to the project here.
Bevy is a data-driven game engine built in Rust. It uses an ergonomic entity component system (ECS) where your systems are plain rust functions.
Bevy in its current state 0.17
does not yet have an editor. However, if you're thinking of making a game that is code-first and less design heavy Bevy can be an amazing choice. Simulations work especially well.
Getting started
To start we create a new project with Cargo:
cargo new pong && cd pong
This puts us inside the new project folder pong
which will have a Cargo.toml
and src/main.rs
.
Your Cargo.toml
will look like this:
[package]
name = "pong"
version = "0.1.0"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
To use Bevy we need to add it as a dependency. We can either manually add it to the [dependencies]
section of our Cargo.toml
or use the cargo add
command:
cargo add bevy
When we run this, the dependency gets added for us in the Cargo.toml
under the [dependencies]
section:
[dependencies]
bevy = "0.17"
Compiling our project
Rust code (the stuff we type) needs to be translated into machine code that can be executed by your CPU. Your source code gets compiled which will generate an executable binary. This binary will contain all the instructions ready to be executed by your operating system.
To compile and run our code we use cargo:
cargo build
This will download your dependencies, compile the src/main.rs
, and build the executable in the target/debug
folder at the root of your project.
The first time you run this, it will be quite slow. The external dependencies need to be downloaded and compiled by your system, and Bevy has quite a few.
But notice that when we compile again its nearly instant. This is because the dependencies have already been compiled, there is no need to start from square one. Cargo is smart enough to detect what needs to be recompiled.
This means that even though your initial compilation will be slow, as we build our game your compiler is only going to compile the things that have changed. So unless we update our Bevy version it won't compile your dependencies again.
What is interesting is that we never make use of our dependencies, yet they still get compiled. In an actual release this code would be pruned automatically during dead code elimination if we didn't use
it anywhere.
Once it has been built we can run the executable:
cargo run
In fact, most of the time you will just be running cargo run
which will also run cargo build
.
Our src/main.rs
currently just outputs "Hello, world!" to the console:
fn main() {
println!("Hello, world!");
}
Creating an app
At its core, a game is really just a big loop where we draw the results of our game logic many times a second on the screen.
The App
is the thing responsible for letting us specify exactly how this loop should work.
We can change the src/main.rs
to the worlds most minimal game loop:
use bevy::prelude::*;
fn main() {
App::new().run();
}
The first line use bevy::prelude::*
is what lets us call App::new()
. We are saying grab all the public constants, functions, etc from bevy::prelude
and make them available at the top level of our own script.
The prelude is a Rust idiom where library authors can make the most commonly used elements of their libraries available from one place. Rather than many use
statements each specifying a different part of the library to import.
Now, lets run our game:
cargo run
Which runs, but outputs absolutely nothing. Even our "Hello world" from before is gone. Not much of a game loop...
Your app ran your game logic, there just wasn't any. So it happily finished.
Adding a plugin
Plugins are Bevy's way of grouping up a bunch of functionality into something you can plug into your game.
Bevy includes DefaultPlugins
in the bevy::prelude
which gives us all of the obvious things our game would need:
Plugin | Description |
---|---|
DiagnosticsPlugin | Adds core diagnostics |
FrameCountPlugin | Adds frame counting functionality |
HierarchyPlugin | Handles Parent and Children components |
InputPlugin | Adds keyboard and mouse input |
LogPlugin | Adds logging to apps |
PanicHandlerPlugin | Adds sensible panic handling |
ScheduleRunnerPlugin | Configures an App to run its Schedule according to a given RunMode |
TaskPoolPlugin | Setup of default task pools for multithreading |
TimePlugin | Adds time functionality |
TransformPlugin | Handles Transform components |
TypeRegistrationPlugin | Registers types for reflection (dynamic lookup of types at runtime) |
Then depending on the features you enable (all enabled by default) you have more plugins that are added:
Plugin | Feature | Description |
---|---|---|
AccessibilityPlugin | bevy_window | Adds non-GUI accessibility functionality |
AnimationPlugin | bevy_animation | Adds animation support |
AssetPlugin | bevy_asset | Adds asset server and resources to load assets |
AudioPlugin | bevy_audio | Adds support for using sound assets |
DevToolsPlugin | bevy_dev_tools | Enables developer tools in an App |
CiTestingPlugin | bevy_ci_testing | Helps instrument continuous integration |
CorePipelinePlugin | bevy_core_pipeline | The core rendering pipeline |
DebugAssetPlugin | debug_asset_server | Adds stuff that helps debugging your assets |
GltfPlugin | bevy_gltf | Adds support for loading gltf models |
GilrsPlugin | bevy_gilrs | Adds support for gamepad inputs |
GizmoPlugin | bevy_gizmos | Provides an immediate mode drawing api for visual debugging |
GltfPlugin | bevy_gltf | Allows loading gltf based assets |
ImagePlugin | bevy_render | Adds the Image asset and prepares them to render on your GPU |
PbrPlugin | bevy_pbr | Adds physical based rendering with StandardMaterial etc |
PipelinedRenderingPlugin | bevy_render | Enables pipelined rendering which makes rendering multithreaded |
RenderPlugin | bevy_render | Sets up rendering backend powered by wgpu crate |
ScenePlugin | bevy_scene | Loading and saving collections of entities and components to files |
SpritePlugin | bevy_sprite | Handling of sprites (images on our entities) |
TextPlugin | bevy_text | Supports loading fonts and rendering text |
UiPlugin | bevy_ui | Adds support for UI layouts (flex, grid, etc) |
WindowPlugin | bevy_window | Provides an interface to create and manage Window components |
WinitPlugin | bevy_winit | Interface to create operating system windows (to actually display our game) |
A simple one line change will dramatically change the outcome of running our app:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.run();
}
Now when we cargo run
a window will pop up! Its just a black background window, but at least its something.
Scheduling a system
So we have an app, which is running a loop. How do we add our game logic?
If we want something to execute at some point in our game loop, we need to schedule it. We do this by adding a system.
Bevy has done a lot of work to make adding systems as ergonomic as possible. To us they look like plain Rust functions.
So lets define our first system:
fn hello_world() {
println!("Hello world");
}
Then we need to tell our app to run this system in at a particular point in our schedule. We only want this to run once so we will give it a schedule label of Startup
:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, hello_world)
.run();
}
Now when we cargo run
now we see "Hello world" in the console (as well as some other logging information about our app that came from the DefaultPlugins
).
We scheduled the hello_world
system to run during the Startup
schedule.
Bevy uses schedules to group systems together and run them at a specific part of the main loop. The three most common are:
Startup
run once at the start of our gameUpdate
run every time we loopFixedUpdate
run every X seconds
So lets change the schedule to Update
:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Update, hello_world)
.run();
}
Now "Hello world" outputs rapidly to the console, once for every loop our game goes through.
It is common for beginners to struggle whether to use Update
or FixedUpdate
. In general you should prefer FixedUpdate
unless you absolutely need per-frame updates for your logic.
Creating a ball
Ok, enough printing to the console. Its time to make something more interesting.
Bevy uses an entity component system (ECS). We've already seen systems. Now its time to introduce entities and components.
Lets write a system to spawn a ball on the screen. Spawning something just means creating an entity in our game world.
fn spawn_ball(mut commands: Commands) {
println!("Spawning ball...");
commands.spawn_empty();
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, spawn_ball)
.run();
}
We just spawned our first entity.
An Entity
is really an ID. Kind of like a pointer we can use to find the components that are associated to it. Bevy is storing all our components in big arrays of the same type, and this entity is the index in each array that makes up that entity's data.
An astute reader might have noticed we never actually called our system ourselves, yet we still provided it an argument of Commands
.
fn spawn_ball(mut commands: Commands) {
// ...
}
This is because Bevy uses a technique is called dependency injection. The way Bevy accomplishes this is quite complicated. For us its enough to appreciate and thank them that this is so ergonomic.
Basically, so long as we use special parameters called system parameters, our regular rust functions can be easily turned into Bevy systems.
Operations that change the game world like spawning entities, or writing messages will mutate and therefore need exclusive access to your World
. Bevy tries very hard to run your systems in parallel.
To avoid blocking the main loop, our systems will use the Commands
interface to tell Bevy to add commands to a queue that will make the changes all at once in a later system.
commands.spawn_empty();
spawn_empty
is a method on our Commands
which will create a new Entity
.
If this were a traditional database backed application, you can think of adding an entity just like adding a new row.
Even though we added an entity, we won't be able to see it for two reasons:
- We are missing a
Camera
to see anything at all - There is nothing about our new entity that needs to be drawn on the screen
Entities themselves are not the things being rendered. Instead the components of an entity are what determine if Bevy draws them on the screen at all.
Creating a camera
First, lets solve the missing camera by creating one:
fn spawn_camera(mut commands: Commands) {
commands
.spawn_empty()
.insert(Camera2d);
}
Here we start out the same way as above with a spawn_empty
but then we chain on an insert
and pass in a Camera2d
component.
Inserting just means to associate the components to an Entity
by placing it inside the array of other components at an index that matches that entity's index.
Components are stored in big arrays of the same type for performance reasons. We essentially added a new column and associated it to our row (the entity).
In fact, spawning entities and components together is so common that there is a much more convenient spawn
method you can use to say the same thing:
fn spawn_camera(mut commands: Commands) {
commands
.spawn(Camera2d);
}
This is just like before, it will spawn a new entity and insert our component. Most of the time you will be using spawn
unless you're adding to an existing entity.
For spawning many components at once on a new entity, we can give spawn
a tuple:
fn spawn_camera(mut commands: Commands) {
commands
.spawn((
Camera2d,
Transform::from_xyz(0., 0., 0.)
));
}
The Camera2d
component will also add any of the other required components it needs onto your entity if you have not added them yourself.
For example, lets look at an abbreviated definition of Camera2d
:
#[derive(Component, Default)]
#[require(
Camera,
// ... more stuff here
)]
pub struct Camera2d;
You can see the require
macro being used to specify the other components that have to be added to any entity with a Camera2d
on it.
So when we add a Camera2d
to our entity, we get the Camera::default()
component added too.
The required components will not add any components that already exist, and you cannot have two components of the same type on any single entity. So if we added our own Camera
component it would override the one given to us by Camera2d
.
The #[require]
macro comes from Bevy's new required components which is a major change introduced to the API in 0.15
.
Finally we have to add this new system to the App
definition:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.run();
}
Running our game now things have changed: Instead of a completely black window we get a lighter background. Something is actually rendering on the screen now because we have opened our eyes (by adding a camera) to the game world.
Drawing a ball
Bevy draws things on the screen every game loop through its pipelined renderer. We added this through the DefaultPlugins
which added the RenderPlugin
.
This plugin added a few systems responsible for taking certain components from our world, sending their data to the GPU and then drawing the results on the screen.
To make these systems draw something we need 3 things:
- A
Transform
to give a position on the screen - A
Mesh2d
which provides the shape - A
MeshMaterial2d
which defines how the renderer should paint the shape
Each of these is handled by a separate component that the systems from the RenderPlugins
will iterate over.
Representing the ball in space
For positioning things Bevy has the built-in component Transform
which is made up of 3 fields that you will recognize from other engines:
- Translation: its coordinates in the game
- Rotation: where it is pointing
- Scale: how big it is
We could use a Transform
directly as our ball's position, but we really shouldn't.
First of all, for our game, we won't need rotation or scale. These will remain static.
Second, what it means to move right or left depends on the scale of our window.
You have your logical position which is our position in the game world. This would be the same no matter how big your monitor was. Then we have our physical position which is where you are on the rendered window and would change depending on your display settings.
If we want the screen to be resizable we don't want to mix our logical position with the physical position of where it will be rendered on the screen.
A better alternative is to use a separate Position
that we control ourselves, and then we can project that position onto the transform in a single separate system.
So we can start by representing our logical position with a new component:
#[derive(Component)]
struct Position(Vec2);
This is our first real component. We create them by using the Component
macro (that #[derive(Component)]
one line above).
Our Position
component takes just a single positional field of a Vec2
which we use to represent our logical position inside our game world.
By creating a component, we essentially made a new optional column, following our database analogy, on any entity. When we insert
this onto an entity we are filling in the value of that column for that particular row.
Now we need something to mark our entity is a ball, rather than a wall or a paddle.
#[derive(Component)]
struct Ball;
For this component, it's enough to just have it on our entity. It doesn't need any data. When you use a component in this way its called a marker component.
Now you might be thinking, why don't we just do this:
#[derive(Component)]
struct Ball {
position: Position
}
The reason is so that we can treat positions generically. We will be writing a system that works over all things that have Position
and we don't want to be searching through all these different components and their fields to find them.
Now, we also will want to make it easy for us to create our ball. And we could right now by assembling the pieces ourselves:
fn spawn_ball(mut commands: Commands) {
commands
.spawn((Position(Vec2::ZERO), Ball))
}
But what happens later in our game when we add balls in other systems? We would need to remember all the components that made up what it means to be a ball.
This is where required components come in handy:
#[derive(Component, Default)]
#[require(Transform)]
struct Position(Vec2);
#[derive(Component)]
#[require(Position)]
struct Ball
By adding a require
macro to our ball we are telling Bevy that any entity with a Ball
should also be spawned with a Position
. So long as our Position
has a default, it will add that default if we do not add our own.
We will need the Transform
for Bevy's renderer to actually position our component on the screen. We add this to our logical position so that everything can be kept in sync by a separate system we will build.
Because our Ball
requires Position
and Position
requires Transform
, spawning a single Ball
component gives us:
Ball
Position
Transform
So with all this together we can add our systems:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.add_systems(FixedUpdate, project_positions)
.run();
}
fn spawn_camera(mut commands: Commands) {
commands.spawn(Camera2d);
}
fn spawn_ball(mut commands: Commands) {
println!("Spawning ball...");
commands.spawn(Ball)
}
fn project_positions(mut positionables: Query<(&mut Transform, &Position)>) {
for (mut transform, position) in &mut positionables {
transform.translation = position.0.extend(0.);
}
}
Notice that even though we made a Position
, we still need to add the Transform
component so that Bevy knows where to place us. The RenderPlugins
plugin is responsible for that are querying for these components and drawing them on the screen.
Our project_positions
system is there to take our own Position
and update Bevy's generic Transform
to keep them in sync.
You of course don't have to do things this way. However, for explaining the inner workings of Bevy and in my own findings in game development I find it quite useful.
Lets take a closer look at the projection system:
fn project_positions(mut positionables: Query<(&mut Transform, &Position)>) {
// Here we are iterating over the query to get the
// components from our game world
for (mut transform, position) in &mut positionables {
// Extend is going to turn this from a Vec2 to a Vec3
transform.translation = position.0.extend(0.);
}
}
The Transform
translation field is a Vec3
. Even in 2D (assuming a top down view) it represents how "close" to us the object is. So we need to extend
our Vec2
into a Vec3
.
An alternative could have been to use a Velocity(Vec3)
but its nicer to simplify our vector math in 2D and not worry about the z-index until we project it into 3D space (using Vec2::extend
).
This is one of the key advantages of using our own Position
as opposed to capitulating to the needs of Bevy's Transform
.
We also introduced a new system parameter: Query
. We gave it one generic argument: (&mut Transform, &Position)
Every Query<D, F>
actually has two generic arguments:
- QueryData: The components we want returned
- QueryFilter: An optional filter to only fetch components from entities that satisfy it
However, in our system we are only using the QueryData
and no filter. We basically told bevy:
Fetch us all the
Transform
andPosition
components for entities that have aTransform
ANDPosition
component.
Queries are enumerable objects that fetch our components from the game world, but only when we iterate over them. That means you don't pay the cost until they fetch the data from the game world by enumerating them.
Giving our ball a shape
To render a shape onto the screen we need two things:
- Mesh: the transparent shape of our object
- Material: the texture we should paint onto the shape
For defining our shape we use a Mesh2d
that will store all the vertices (points in space) that make up the shape we want.
Then for the texture that gets put onto our shape we will need a MeshMaterial2d
that we give a Color::srgb
which will tell Bevy's renderer to paint our shape one solid color.
When you add assets like a Mesh2d
you add them to a resource specific to that asset type. So all Mesh2d
are stored in your Assets<Mesh2d>
for example. You will add them and Bevy will return you a Handle
to that asset.
A Handle
is just like an Entity
. It's a unique ID for an asset we have loaded. So a Mesh2d
is not actually asking us for the whole mesh. It really just wants the unique ID of that mesh that gets stored in an Assets<T>
.
So there will be both a Assets<Mesh2d>
and an Assets<MeshMaterial2d>
already available to us as resources, which were provided by the AssetsPlugin
(which we added with the DefaultPlugins
).
Before we can pass them to our entity we need to add our Assets<T>
.
const BALL_SHAPE: Circle = Circle::new(BALL_SIZE);
const BALL_COLOR: Color = Color::srgb(1., 0., 0.);
fn spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
// `Assets::add` will load these into memory and return a `Handle` (an ID)
// to these assets. When all references to this `Handle` are cleaned up
// the asset is cleaned up.
let mesh = meshes.add(BALL_SHAPE);
let material = materials.add(BALL_COLOR);
// Here we are using `spawn` instead of `spawn_empty` followed by an
// `insert`. They mean the same thing, letting us spawn many components on a
// new entity at once.
commands.spawn((Ball, Mesh2d(mesh), MeshMaterial2d(material)));
}
This is the first time we are seeing a Resource
. These are just like our components, but don't belong to a specific Entity
. You can think about them like singleton components.
Instead of using a Query
for these resources we can ask for them directly as a generic arguments to Res
or ResMut
.
When you see ResMut<Assets<Mesh>>
you can think of it as saying:
Give me exclusive mutable access to the resource of type
Assets<Mesh>
We actually load the asset by calling Assets<T>::add
which does the work of adding our assets and returns us a Handle<T>
for that type of asset.
// Both of these return a Handle<T> to the assets
// rather than the full data of the asset itself
let mesh = meshes.add(Circle::new(BALL_SIZE));
let material = materials.add(BALL_COLOR);
Now when we run our game we get a tiny red dot in the center of our game window. That's our ball!
Moving our ball
So not much of a game yet, but we could improve it by adding some dynamic movement.
Lets start with a simple goal: move our ball to the right steadily.
We could do so by changing our Position
every loop to be slightly more to the right than it is currently.
fn move_ball(
// Give me all positions that also contain a `Ball` component
mut ball: Query<&mut Position, With<Ball>>,
) {
if let Ok(mut position) = ball.single_mut() {
position.0.x += 1.0
}
}
We have a query that uses both generic arguments. Query<D, F>
The first one D
is what we want returned. So we are asking for all the Position
components.
The second F
is a filter which is modifying our request to only get Position
components from entities which also have a Ball
. The upside of using the filter is that the Ball
is not actually returned from the query. It is only changing which Position
components get returned to us.
Now this could be fine, using single_mut
to get access to the position. But given that we know there should only be one ball we can use the Single
system parameter instead:
fn move_ball(mut position: Single<&mut Position, With<Ball>>) {
position.0.x += 1.0;
}
Single
is special version of a Query
that will skip the system if none or more than one match of the query exists.
Now we need to schedule our system before we project the positions:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.add_systems(
Update,
(
// Add our `move_ball` system to run before
// we project our positions so we are not reading
// movement one frame behind
move_ball.before(project_positions),
project_positions
),
)
.run();
}
We moved the ball by changing its Position
every FixedUpdate
. This modified position is then projected onto the Transform
which will cause Bevy's RenderPlugins
to update its physical position on the screen.
Running our game we can see our ball move steadily to the right each frame.
Bouncing off a paddle
Next up lets make some action. At minimum our game should have some interaction between its entities.
Once again we need to create the component and what it requires:
#[derive(Component)]
#[require(Position)]
struct Paddle;
Now we can make a similar system to spawn_ball
but for our two paddles:
const PADDLE_SHAPE: Rectangle = Rectangle::new(20., 50.);
const PADDLE_COLOR: Color = Color::srgb(0., 1., 0.);
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
let mesh = meshes.add(PADDLE_SHAPE);
let material = materials.add(PADDLE_COLOR);
commands.spawn((
Paddle,
Mesh2d(mesh),
MeshMaterial2d(material),
Position(Vec2::new(250., 0.)),
));
}
And we will schedule this system with our other Startup
systems:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (
spawn_ball,
spawn_camera,
spawn_paddles
))
.add_systems(
FixedUpdate,
(move_ball, project_positions.after(move_ball))
)
.run();
}
Running our game now we can see the ball moves to the right and then... Goes right through our paddle.
There is nothing in our game logic that says that balls and paddles care about each other. In other engines like Godot or Unity there is usually some kind of default physics but in Bevy you start from scratch.
There are physics libraries like Rapier or Avian which get you started faster but for the purposes of this tutorial we will make everything ourselves.
Handling collisions
To bounce we will need to detect collisions and then change the movement of our ball.
Immediately our simple "move to the right" logic of our move_ball
function will need to be reworked. If other systems need to set the movement of our ball then we will need something more dynamic.
Instead of our ball moving 1.0
to the right every frame it should move whatever the Velocity
of our ball is.
const BALL_SPEED: f32 = 2.;
// This component is a tuple type, we can access the `Vec2` it holds
// by using the position of the item in the tuple
// e.g. velocity.0 which would be a `Vec2`
#[derive(Component, Default)]
struct Velocity(Vec2);
#[derive(Component)]
#[require(
Position,
Velocity = Velocity(Vec2::new(-BALL_SPEED, BALL_SPEED)),
)]
struct Ball;
We gave our ball a Velocity
with a different default that wasn't 0 so that our ball actually moves when its first spawned.
Velocity
here is just something we invented, a new component we will make that lets us set a direction we desire to move the next time we do. That way our movement system can just read this value and move us.
The alternative would be for many systems to move the ball which can get messy and impossible to only move a set distance each frame. The less each system knows about the whole game the better.
fn move_ball(ball: Single<(&mut Position, &Velocity), With<Ball>>) {
let (mut position, velocity) = ball.into_inner();
position.0 += velocity.0 * BALL_SPEED;
}
Now our ball moves just like before but its deriving this movement from the Velocity
component on the ball.
We have our velocity determining where we go next frame, so the logic for our collision system becomes simple:
- If we are colliding on the top or bottom: reverse our y velocity
- If we are colliding on the left or right: reverse our x velocity
To build a simple collision system we can use some of the types from bevy_math
, which is a convenient module holding some common operations:
use bevy::math::bounding::{
Aabb2d,
BoundingVolume,
IntersectsVolume
};
Aabb2d
represents a bounding box for our gutters, paddles or ball.
The other two types BoundingVolume
and IntersectsVolume
are traits that won't be used directly but are implemented in the first two types and will need to be in scope.
In a 2D game like Pong we can simplify things down to 4 types of collisions which we can represent as an enum:
#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum Collision {
Left,
Right,
Top,
Bottom,
}
With all this we have enough to build a simple function we can call in any system to check for collisions between our ball and another area:
// Returns `Some` if `ball` collides with `wall`. The returned `Collision` is the
// side of `wall` that `ball` hit.
fn collide_with_side(ball: Aabb2d, wall: Aabb2d) -> Option<Collision> {
if !ball.intersects(&wall) {
return None;
}
let closest_point = wall.closest_point(ball.center());
let offset = ball.center() - closest_point;
let side = if offset.x.abs() > offset.y.abs() {
if offset.x < 0. {
Collision::Left
} else {
Collision::Right
}
} else if offset.y > 0. {
Collision::Top
} else {
Collision::Bottom
};
Some(side)
}
Up until now we have defined our size during the creation with the shape of the Mesh
. But its going to be overly complicated to get our handle and constantly query our assets for the shape.
Once again it would be better for our mesh to derive from some kind of Collider
that we create:
#[derive(Component, Default)]
struct Collider(Rectangle);
Then we can update our Ball
definition with a default Collider
through its required components:
#[derive(Component)]
#[require(
Position,
Velocity = Velocity(Vec2::new(-BALL_SPEED, BALL_SPEED)),
Collider = Collider(Rectangle::new(BALL_SIZE, BALL_SIZE))
)]
struct Ball;
The same Collider
can be used represent our paddles:
#[derive(Component)]
#[require(
Position,
Collider = Collider(PADDLE_SHAPE)
)]
struct Paddle;
Here we are giving the paddle a default collider. We can still override it but the default for a Collider
will be a Vec2::ZERO
so we give it a better default for our paddles.
Now that our balls have colliders as components we can easily incorporate our collide_with_side
function into a new system that will handle all the collisions for our game:
impl Collider {
fn half_size(&self) -> Vec2 {
self.0.half_size
}
}
fn handle_collisions(
ball: Single<(&mut Velocity, &Position, &Collider), With<Ball>>,
other_things: Query<(&Position, &Collider), Without<Ball>>,
) {
let (mut ball_velocity, ball_position, ball_collider) = ball.into_inner();
for (other_position, other_collider) in &other_things {
if let Some(collision) = collide_with_side(
Aabb2d::new(ball_position.0, ball_collider.half_size()),
Aabb2d::new(other_position.0, other_collider.half_size()),
) {
match collision {
Collision::Left => {
ball_velocity.0.x *= -1.;
}
Collision::Right => {
ball_velocity.0.x *= -1.;
}
Collision::Top => {
ball_velocity.0.y *= -1.;
}
Collision::Bottom => {
ball_velocity.0.y *= -1.;
}
}
}
}
}
Then we need to add this system to our App
. We will schedule it to run after
the move_ball
system.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_paddles, spawn_camera))
.add_systems(
FixedUpdate,
(
project_positions,
move_ball.before(project_positions),
handle_collisions.after(move_ball),
),
)
.run();
}
The collide_with_side
function we created returns a Collision
which is an enum that we match on.
Then we flip either the x
or y
components of our velocity depending on which side the collision happens on.
When we run our game the ball slides to the right, hits the paddle and bounces off just like we intended!
Spawning the other paddle
All we have done so far is placed a paddle 250 units to the right of our ball.
When we add a second paddle, we will need to start knowing which one is the Player
and which is the Ai
. So lets add two marker components:
#[derive(Component)]
struct Player;
#[derive(Component)]
struct Ai;
These components will let us query for each paddle separately later.
Each paddle should sit at the edge of the screen. We could eyeball our values and slowly iterate but where does the window size come from?
Bevy adds a Window
component to an entity that is representing our real rendered window. This gets added by the DefaultPlugins
which loads the WindowPlugin
.
This means we can figure out where to place the paddles:
- The default
Window
has a resolution of1280x720
. - Our
Camera2d
has its nose pointed directly at the origin(0, 0)
. - If we set our ball's initial position to
(0, 0)
then it spawns center of the screen (our camera's origin).
So we can query the window to find its size and place ourselves accordingly. We will also want the paddles to be different colors to better identify our Player
vs Ai
.
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Single<&Window>,
) {
let mesh = meshes.add(PADDLE_SHAPE);
let material = materials.add(PADDLE_COLOR);
let half_window_size = window.resolution.size() / 2.;
let padding = 20.;
let player_position = Vec2::new(-half_window_size.x + padding, 0.);
commands.spawn((
Player,
Paddle,
Mesh2d(mesh.clone()),
MeshMaterial2d(material.clone()),
Position(player_position),
));
let ai_position = Vec2::new(half_window_size.x - padding, 0.);
commands.spawn((
Ai,
Paddle,
Mesh2d(mesh.clone()),
MeshMaterial2d(material.clone()),
Position(ai_position),
));
}
Now our paddles are on either side of the screen and if we change how our window gets initialized or change the resolution before we spawn our paddles they will adjust automatically.
Creating the gutters
We need the ball to stay in bounds so we can add some vertical movement without it flying off the screen.
Adding our gutters looks a lot like adding our paddles but more concerned about the y
axis than the x
.
#[derive(Component)]
#[require(Position, Collider)]
struct Gutter;
const GUTTER_COLOR: Color = Color::srgb(0., 0., 1.);
const GUTTER_HEIGHT: f32 = 20.;
fn spawn_gutters(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Single<&Window>,
) {
let material = materials.add(GUTTER_COLOR);
let padding = 20.;
let gutter_shape = Rectangle::new(window.resolution.width(), GUTTER_HEIGHT);
let mesh = meshes.add(gutter_shape);
let top_gutter_position =
Vec2::new(0., window.resolution.height() / 2. - padding);
commands.spawn((
Gutter,
Mesh2d(mesh.clone()),
MeshMaterial2d(material.clone()),
Position(top_gutter_position),
Collider(gutter_shape)
));
let bottom_gutter_position =
Vec2::new(0., -window.resolution.height() / 2. + padding);
commands.spawn((
Gutter,
Mesh2d(mesh.clone()),
MeshMaterial2d(material.clone()),
Position(bottom_gutter_position),
Collider(gutter_shape)
));
}
Now when we run our game we see a nicely fit border on the top and bottom for our blue gutters.
Also, we never added any specific code for handing collisions with a gutter and yet our handle_collisions
system still worked.
Because our gutters are made up of both a Shape
and a Position
the system checked for collisions with the ball on our newly placed gutters. There is no gutter specific movement required.
Moving our paddle
Input is handled by Bevy and can be read by us with the ButtonInput
resource:
const PADDLE_SPEED: f32 = 5.;
fn handle_player_input(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut paddle_velocity: Single<&mut Velocity, With<Player>>,
) {
if keyboard_input.pressed(KeyCode::ArrowUp) {
paddle_velocity.0.y = PADDLE_SPEED;
} else if keyboard_input.pressed(KeyCode::ArrowDown) {
paddle_velocity.0.y = -PADDLE_SPEED;
} else {
paddle_velocity.0.y = 0.;
}
}
fn move_paddles(mut paddles: Query<(&mut Position, &Velocity), With<Paddle>>) {
for (mut position, velocity) in &mut paddles {
position.0 += velocity.0;
}
}
The keyboard_input.pressed
will return true during a frame where the key-code we pass it matches to a key pressed by us.
So when we press up we set our desired velocity upwards (+1.0), move_paddles
will use this to move our paddles scaled to our PADDLE_SPEED
.
When we run the game we can move our paddle outside of each of the gutters. There is nothing preventing us from colliding with them.
We can write a system to check if we collide with any of the gutters and to force our position inside the bounds when we do:
fn constrain_paddle_position(
mut paddles: Query<
(&mut Position, &Collider),
(With<Paddle>, Without<Gutter>),
>,
gutters: Query<(&Position, &Collider), (With<Gutter>, Without<Paddle>)>,
) {
for (mut paddle_position, paddle_collider) in &mut paddles {
for (gutter_position, gutter_collider) in &gutters {
let paddle_aabb =
Aabb2d::new(paddle_position.0, paddle_collider.half_size());
let gutter_aabb =
Aabb2d::new(gutter_position.0, gutter_collider.half_size());
if let Some(collision) = collide_with_side(paddle_aabb, gutter_aabb) {
match collision {
Collision::Top => {
paddle_position.0.y = gutter_position.0.y
+ gutter_collider.half_size().y
+ paddle_collider.half_size().y;
}
Collision::Bottom => {
paddle_position.0.y = gutter_position.0.y
- gutter_collider.half_size().y
- paddle_collider.half_size().y;
}
_ => {}
}
}
}
}
}
We are reusing the logic from our collide_with_side
to determine which way we need to push the paddle (either up or down) to keep it from going outside of the gutters.
Then we need to schedule these systems inside our App
:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(
Startup,
(spawn_ball, spawn_paddles, spawn_camera, spawn_gutters),
)
.add_systems(
FixedUpdate,
(
project_positions,
move_ball.before(project_positions),
handle_collisions.after(move_ball),
move_paddles.before(project_positions),
handle_player_input.before(move_paddles),
constrain_paddle_position.after(move_paddles),
),
)
.run();
}
Scoring
We need to represent our score. There is only one score per game. It's a piece of data so it has to be some kind of Component
.
This brings up a good question though: Which entity should hold the score?
In situations where you only want one piece of data to exist and it does not make sense to put on any single entity, then you have a great use case for a Resource
instead.
Let's add our first resource to the game:
#[derive(Resource)]
struct Score {
player: u32,
ai: u32,
}
A Resource
is just a component without an entity. They are singleton components we can easily access from our systems.
After we define a resource we need to add it to our app's definition with insert_resource
. This will make it available when our game starts.
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Score { player: 0, ai: 0 })
// ...
}
When someone scores in pong a couple of things have to happen:
- Reset the ball's position and velocity
- Update the score of the players
But a problem happens here, how can we keep two separate systems for each of these tasks without duplicating detecting the goal in the first place?
For example if we reset the ball before the system that updates the score can run then the score update system won't realize the goal.
Or we could combine the logic of both into one big system but ideally we want to keep our systems focused on just one small part of the whole.
In Bevy, there are two ways to communicate between our systems:
- Messages for publish/subscribe style communication within a couple frames
- Events for immediately triggering other systems this frame
We will use events here which will immediately trigger both our systems when someone scores.
#[derive(EntityEvent)]
struct Scored {
entity: Entity,
}
There are two kinds of events:
Event
for global eventsEntityEvent
for events relating to a specific entity
Here we are making an EntityEvent
which expects a struct that contains at least an entity
field. You can rename this by setting the #[event_target]
on the field that holds an Entity
.
#[derive(EntityEvent)]
struct Scored {
#[event_target]
scorer: Entity,
}
Then we need a system that detects a goal and fires our event:
fn detect_goal(
ball: Single<(&Position, &Collider), With<Ball>>,
player: Single<Entity, (With<Player>, Without<Ai>)>,
ai: Single<Entity, (With<Ai>, Without<Player>)>,
window: Single<&Window>,
mut commands: Commands,
) {
let (ball_position, ball_collider) = ball.into_inner();
let half_window_size = window.resolution.size() / 2.;
if ball_position.0.x - ball_collider.half_size().x > half_window_size.x {
commands.trigger(Scored { scorer: *player });
}
if ball_position.0.x + ball_collider.half_size().x < -half_window_size.x {
commands.trigger(Scored { scorer: *ai });
}
}
Finally we need the two systems that will react to these events:
fn reset_ball(
_event: On<Scored>,
ball: Single<(&mut Position, &mut Velocity), With<Ball>>,
) {
let (mut ball_position, mut ball_velocity) = ball.into_inner();
ball_position.0 = Vec2::ZERO;
ball_velocity.0 = Vec2::new(BALL_SPEED, 0.);
}
fn update_score(
event: On<Scored>,
mut score: ResMut<Score>,
is_ai: Query<&Ai>,
is_player: Query<&Player>,
) {
if is_ai.get(event.scorer).is_ok() {
score.ai += 1;
info!("AI scored! {} - {}", score.player, score.ai);
}
if is_player.get(event.scorer).is_ok() {
score.player += 1;
info!("Player scored! {} - {}", score.player, score.ai);
}
}
These systems are special because of their first system parameter: On
. This makes them observers which are callbacks that respond to certain events.
With these two systems we can add them to our app definition. But we don't add them with the usual add_systems
call. Instead we add them as global observers with add_observer
:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Score { player: 0, ai: 0 })
.add_systems(
Startup,
(spawn_ball, spawn_paddles, spawn_camera, spawn_gutters),
)
.add_systems(
FixedUpdate,
(
project_positions,
move_ball.before(project_positions),
handle_collisions.after(move_ball),
move_paddles.before(project_positions),
handle_player_input.before(move_paddles),
constrain_paddle_position.after(move_paddles),
detect_goal.after(move_ball),
),
)
// Here we are adding our observer systems as global observers
.add_observer(reset_ball)
.add_observer(update_score)
.run();
}
Displaying our score
Now that we have a score we need to display it on the screen.
To do so we need to build some UI entities that can hold the components that store each score:
#[derive(Component)]
struct PlayerScore;
#[derive(Component)]
struct AiScore;
Then we can add these components to some entities which will render themselves in the UI layer of our game.
To do this we create entities which have a Node
component on them. These Node
components determine the layout within the window, outside of your game's world positions.
fn spawn_scoreboard(mut commands: Commands) {
// Create a container that will center everything
let container = Node {
width: percent(100.0),
height: percent(100.0),
justify_content: JustifyContent::Center,
..default()
};
// Then add a container for the text
let header = Node {
width: px(200.),
height: px(100.),
..default()
};
// The players score on the left hand side
let player_score = (
PlayerScore,
Text::new("0"),
TextFont::from_font_size(72.0),
TextColor(Color::WHITE),
TextLayout::new_with_justify(Justify::Center),
Node {
position_type: PositionType::Absolute,
top: px(5.0),
left: px(25.0),
..default()
},
);
// The AI score on the right hand side
let ai_score = (
AiScore,
Text::new("0"),
TextFont::from_font_size(72.0),
TextColor(Color::WHITE),
TextLayout::new_with_justify(Justify::Center),
Node {
position_type: PositionType::Absolute,
top: px(5.0),
right: px(25.0),
..default()
},
);
commands.spawn((
container,
children![(header, children![player_score, ai_score])],
));
}
We spawned a player and AI scoreboard which has a Text
component that will get rendered to the screen with systems added from the TextPlugin
. Then we overrode its required components to give it the style and position we need.
The children!
macro is our first time creating a relationship in Bevy. In this case we are using the builtin Children
/ChildOf
relationship. This lets an entity have many Children
and each child contains a ChildOf(Entity)
pointing to the parent.
Bevy keeps the Children
in sync with each of the ChildOf
components so we can easily access them from either direction.
If something is a ChildOf
another entity, it will keep its Transform
in sync and relative to that parent. This is how these Node
components can be placed relatively to one another.
Next up, we need to keep the score in sync:
fn update_scoreboard(
mut player_score: Single<&mut Text, (With<PlayerScore>, Without<AiScore>)>,
mut ai_score: Single<&mut Text, (With<AiScore>, Without<PlayerScore>)>,
score: Res<Score>,
) {
if score.is_changed() {
player_score.0 = score.player.to_string();
ai_score.0 = score.ai.to_string();
}
}
Here we are using the Score
resource we created and ask if it is_changed
which uses Bevy's change detection features to only run our logic on frames where the scores value has changed.
Programming the AI player
To program the computer controlled player we can do some simple vector math to get our desired Velocity
for the left paddle.
Subtracting two vectors that represent coordinates in our game will give us a new vector pointing from one to the other.
We can then use this new vector's y
component to set our desired movement.
fn move_ai(
ai: Single<(&mut Velocity, &Position), With<Ai>>,
ball: Single<&Position, With<Ball>>,
) {
let (mut velocity, position) = ai.into_inner();
let a_to_b = ball.0 - position.0;
velocity.0.y = a_to_b.y.signum() * PADDLE_SPEED;
}
Its very common to have lots of marker components to allow you to query specific parts of your game.
Congrats, running your game now you can play against your opponent. Try tweaking the constant values for speed of the ball and paddles to try and make it fun.
Finishing up and next steps
The final app definition should look something like this:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.insert_resource(Score { player: 0, ai: 0 })
.add_systems(
Startup,
(
spawn_ball,
spawn_paddles,
spawn_camera,
spawn_gutters,
spawn_scoreboard,
),
)
.add_systems(
FixedUpdate,
(
project_positions,
move_ball.before(project_positions),
handle_collisions.after(move_ball),
move_paddles.before(project_positions),
handle_player_input.before(move_paddles),
constrain_paddle_position.after(move_paddles),
detect_goal.after(move_ball),
update_scoreboard,
move_ai,
),
)
.add_observer(reset_ball)
.add_observer(update_score)
.run();
}
We learned how to build a classic game from scratch using Bevy. This tutorial was designed with care to be an easy introduction for Rust beginners to get something fun on the screen to play around with.
In a more realistic setting you would be using a lot more ecosystem crates to handle the functionality we built from scratch here. Each of them uses similar techniques, but without you having to reinvent the wheel.
A great next step would be to take what we have built and add more serious components in such as:
- avian to handle the physics
- bevy_enhanced_input to handle the input
- bevy_cli for a better linter and developer experience
If you're looking for a grand tour of the ecosystem you can check out the Tainted Coder's Awesome Bevy List.