The goal of this tutorial is to build Pong using a minimal amount of dependencies for the purpose of learning Bevy.
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.13) 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 = "2021"
# 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
to do so automatically:
cargo add bevy
When we run this, the dependency gets added for us in the Cargo.toml
[dependencies]
bevy = "0.13"
Compiling our project
Rust code 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 our code we use cargo:
cargo run
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.
Our src/main.rs
currently just outputs “Hello, world!” to the console:
fn main() {
println!("Hello, world!");
}
Notice that even though we never use
our dependencies they still get compiled
in the binary that gets produced by cargo. In an actual release this code would
be pruned automatically during dead code elimination if we didn’t use
it
anywhere.
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.
Creating an app
At its most simple a game is really just a loop. We execute our game logic, and then draw the results many times a second to get our frames per second (FPS).
An app in Bevy 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 in 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. 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 is highly customizable so a basic app starts out with not much of use to us. We could write our entire world from scratch including allocating windows, creating our own renderer etc, but its not really core to our game.
So Bevy includes DefaultPlugins
in the bevy::prelude
which gives us all of
the obvious things our game would need:
Plugin | Description |
---|---|
LogPlugin | Adds logging to apps |
TaskPoolPlugin | Setup of default task pools for multithreading |
TypeRegistrationPlugin | Registers types for reflection (dynamic lookup of types at runtime) |
FrameCountPlugin | Adds frame counting functionality |
TimePlugin | Adds time functionality |
TransformPlugin | Handles Transform components |
HierarchyPlugin | Handles Parent and Children components |
DiagnosticsPlugin | Adds core diagnostics |
InputPlugin | Adds keyboard and mouse input |
WindowPlugin | Provides an interface to create and manage Window components |
Then depending on the features you enable (all enabled by default) you have more plugins that are added:
Plugin | Feature | Description |
---|---|---|
AssetPlugin | bevy_asset | Adds asset server and resources to load assets |
DebugAssetPlugin | debug_asset_server | Adds stuff that helps debugging your assets |
ScenePlugin | bevy_scene | Loading and saving collections of entities and components to files |
WinitPlugin | bevy_winit | Interface to create operating system windows (to actually display our game) |
RenderPlugin | bevy_render | Sets up rendering backend powered by wgpu crate |
ImagePlugin | bevy_render | Adds the Image asset and prepares them to render on your GPU |
PipelinedRenderingPlugin | bevy_render | Enables pipelined rendering which makes rendering multithreaded |
CorePipelinePlugin | bevy_core_pipeline | The core rendering pipeline |
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) |
PbrPlugin | bevy_pbr | Adds physical based rendering with StandardMaterial etc |
GltfPlugin | bevy_gltf | Allows loading gltf based assets |
AudioPlugin | bevy_audio | Adds support for using sound assets |
GilrsPlugin | bevy_gilrs | Adds support for gamepad inputs |
AnimationPlugin | bevy_animation | Adds animation support |
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 when our game is running we need to schedule it to run in our app’s loop. We do so by scheduling a system.
Bevy has done a lot of work to make adding systems as ergonomic as possible. To us they are just plain Rust functions.
So lets define a new system outside of our main
function:
fn hello_world() {
println!("Hello world");
}
Then we need to tell our app to run this system in a specific schedule:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, hello_world)
.run();
}
By using add_systems
we told Bevy to run our hello_world
function during
the Startup
schedule.
A schedule is responsible for exactly when our system should run. Startup
will
run the system exactly one time when our game starts.
If 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
).
Instead of running it once at startup we can change our schedule and run it every loop instead:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Update, hello_world)
.run();
}
Now “Hello world” gets output every time our game loops.
Creating a ball
Ok enough printing to the console. Its time to make something more interesting.
Lets write a system to spawn our ball:
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();
}
When we run we see our “Spawning ball…” message and I can assure you we have spawned an entity.
Whats interesting is that this works at all. Our system (the spawn_ball
function) takes a positional. And we handed the function in to our add_systems
call. So how did Bevy know to pass this into our function?
Well this technique is called dependency injection and how Bevy accomplishes this is quite complicated. For us its enough to appreciate and thank them that this is so ergonomic.
When we specify the function spawn_ball(mut commands: Commands)
Bevy will know
that when this system gets called it needs the parameter Commands
and provide
it for us automagically.
fn spawn_ball(mut commands: Commands) {}
Commands
is our interface to tell Bevy to do things. When you do stuff like
spawn entities, components or whatever else they do
not happen immediately.
Instead they are scheduled to happen all at once in a system that Bevy provides
to us through one of the parts of the DefaultPlugins
.
This is all to help Bevy run our game faster and more efficiently. If we didn’t do this Bevy would have no hope of optimizing our game because at any time two systems running at the same time might change data the other depended on.
commands.spawn_empty();
spawn_empty
is a method on our Commands
which will create a new Entity
.
An Entity
is really just a unique identifier. It means a “thing” has been
added to our game world and Bevy will keep track of it.
Now the “thing” being in the game world is nice but we won’t be able to see it for two reasons:
- We are missing a camera
- There is nothing about an 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 the render cares about them at all.
Creating a camera
Ok so lets solve the missing camera by creating one:
fn spawn_camera(mut commands: Commands) {
commands.spawn_empty()
.insert(Camera2dBundle::default());
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.run();
}
Here we start out the same way as above with a spawn_empty
but then we chain
on an insert
and pass in a Camera2dBundle
.
A Bundle
is just a collection of components that we create together. The
Camera2dBundle
looks like this:
pub struct Camera2dBundle {
pub camera: Camera,
pub camera_render_graph: CameraRenderGraph,
pub projection: OrthographicProjection,
pub visible_entities: VisibleEntities,
pub frustum: Frustum,
pub transform: Transform,
pub global_transform: GlobalTransform,
pub camera_2d: Camera2d,
pub tonemapping: Tonemapping,
pub deband_dither: DebandDither,
}
The alternative to a bundle is just specifying a bunch of components individually. Its easier to remember the bundle and let the compiler remind us than to keep it all in our head.
When we insert a bundle only the components remain. The bundle itself ceases to exist. We cannot query for them in any of our systems, but we can the individual components that the bundle creates.
Inserting just means to associate the components to an Entity
. Remembering that
an Entity
is really just a unique ID, we are associating that component to that
ID.
Later when we query these components together Bevy will go over each Entity
and find all the components we want that are associated by that ID.
All this indirection and abstraction is done to help improve the performance of our games.
Running our game now things have changed: Instead of a completely black window we get a lighter background. Something (emptyness) is actually rendering on the screen now because we have opened our eyes (by adding a camera) on the game world.
Drawing a ball
Bevy draws things on the screen in its rendering system bevy::renderer
. We
added this when we added 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:
- It needs to be positioned somewhere on the screen
- It needs to have some kind of shape
- It needs to be wrapped in some kind of texture
Each of these is handled by a separate component which 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 our game won’t have rotation or scale. These will remain static.
Second what it means to move right or left depends on the scale of our window. 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.
#[derive(Component)]
struct Position(Vec2);
#[derive(Component)]
struct Ball;
#[derive(Bundle)]
struct BallBundle {
ball: Ball,
position: Position
}
impl BallBundle {
fn new() -> Self {
Self {
ball: Ball,
position: Position(Vec2::new(0., 0.)),
}
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.add_systems(Update, (project_positions))
.run();
}
fn spawn_ball(
mut commands: Commands,
) {
println!("Spawning ball...");
commands
.spawn_empty()
.insert(Transform::default())
.insert(BallBundle::new());
}
fn project_positions(
positionables: Query<(&mut Transform, &Position)>
) {
for (mut transform, position) in &positionables {
transform.translation = position.0.extend(0.);
}
}
We introduced creating our own Bundle
which is a convenient way of grouping many
components that make up being a ball into something we can create as one single
thing.
#[derive(Bundle)]
struct BallBundle {
ball: Ball,
position: Position
}
We separated our internal representation of our Position
from Bevy’s
Transform
.
We apply our internal state to Bevy’s Transform
in one place. This way
our BallBundle
can have its own representation of Position
and we better
model our own game logic rather than the logic of Bevy’s renderer.
fn project_positions(
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 &positionables {
transform.translation = position.0.extend(0.);
}
}
We introduced a new system parameter Query<Q, F>
which has two generic arguments:
- QueryData: The components we want returned
- QueryFilter: An optional filter to only fetch components from entities that satisfy it
We basically told bevy:
Fetch us all the
Transform
andPosition
components for entities have aTransform
ANDPosition
component.
Queries are enumerable objects that fetch our components from the game world only when we iterate over them. That means they are lightweight until they fetch the data from the game world.
Note that this query only has one generic argument and we have not yet used a filter. The first generic is a tuple of two types.
By implementing BallBundle::new
we get a nice API to set the initial velocity
of a new ball but not have to care about the details of the other components.
commands
.spawn_empty()
.insert(Transform::default())
.insert(BallBundle::new());
We added a Transform
outside of our BallBundle
to further separate the
concerns of our model of the game from rendering concerns.
Giving our ball a shape
To render a shape onto the screen we need two things:
- Mesh: the transparent 3d shape of our object
- Material: the texture we should paint onto the shape
For defining our shape we use a Mesh
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 use a ColorMaterial
which will tell Bevy’s renderer how to paint our shape given some kind of
light source. It will basically just paint our shape one solid color.
Bevy has a useful bundle for creating everything we need called
MaterialMesh2dBundle
which comes from the bevy::sprite
crate:
pub struct MaterialMesh2dBundle<M: Material2d> {
pub mesh: Mesh2dHandle,
pub material: Handle<M>,
pub transform: Transform,
pub global_transform: GlobalTransform,
pub visibility: Visibility,
pub computed_visibility: ComputedVisibility,
pub inherited_visibility: InheritedVisibility,
pub view_visibility: ViewVisibility
}
We can see our mesh
and material
as well as the core components that are
needed by the renderer to determine where to place our objects.
A Handle
is just like an Entity
, which is a unique ID. So a
MaterialMesh2dBundle
is not actually asking us for the whole mesh. It really
just wants the unique ID of that mesh that gets stored elsewhere.
The elsewhere is in an Assets<T>
resource which are just like Commands
but
manage loading our assets of a particular type T
into memory. So there will be
both a Assets<Mesh>
and an Assets<ColorMaterial>
already available to us as
resources, provided by the AssetsPlugin
(which we added with the
DefaultPlugins
).
So before we can pass them into the bundle they will need to be created and we
will need a reference to the Handle
of that created asset.
To do so lets add these resources as system parameters to our spawn_ball
system:
use bevy::sprite::MaterialMesh2dBundle;
const BALL_SIZE: f32 = 5.;
fn spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning ball...");
let shape = Mesh::from(Circle::new(BALL_SIZE));
let color = ColorMaterial::from(Color::rgb(1., 0., 0.));
// `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_handle = meshes.add(shape);
let material_handle = materials.add(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((
BallBundle::new(0., 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
},
));
}
Resources are just components without entities. Instead of querying 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.
let mesh_handle = meshes.add(shape);
let material_handle = materials.add(color);
Note that we have to call mesh.into()
to change our Handle<Mesh>
into a
Mesh2dHandle
(ugh). This is so Bevy can process this handle differently in the
2D rendering pipeline.
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
}
Why didn’t we move MaterialMesh2dBundle
into our BallBundle
?
For the exact same reason we didn’t add our Transform
to the BallBundle
,
that is to separate out the rendering concerns from our core game logic.
Even if we wanted to we would need ResMut<Assets<Mesh>>
or pass in a handle
which won’t be available easily in the body of a BallBundle::new
.
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. So that gives us some clues:
- We want to change our rendered position, which we know will come from the
Transform
component - And we want to change this transform’s translation which we will need to do in a system
- And we want to change our position every loop so we know our system should be
scheduled in the
Update
schedule.
So lets give it a go:
fn move_ball(
// Give me all positions that also contain a `Ball` component
mut ball: Query<&mut Position, With<Ball>>,
) {
if let Ok(mut transform, velocity) = ball.get_single_mut() {
position.0.x += 1.0
}
}
fn project_positions(
// Give me all transforms and positions from entities
// that contain both
positionables: Query<(&mut Transform, &Position)>
) {
for (mut transform, position) in &positionables {
// Our position is `Vec2` but a translation is `Vec3`
// so we extend our `Vec2` into one by adding a `z`
// value of 0
transform.translation = position.0.extend(0.);
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera))
.add_systems(
Update,
(
move_ball,
// Add our projection system to run after
// we move our ball so we are not reading
// movement one frame behind
project_positions.after(move_ball)
),
)
.run();
}
We move the ball by changing its Position
every Update
. This modified
position is then projected onto the Transform
which will cause Bevy’s
renderer to display it on a different part of the screen.
fn move_ball(
// Give me all positions that also contain a `Ball` component
mut ball: Query<&mut Position, With<Ball>>,
) {
if let Ok(mut transform, velocity) = ball.get_single_mut() {
position.0.x += 1.0
}
}
We used the method get_single_mut
to get a mutable Transform
from our ball.
We are choosing to use get_single_mut
because currently we only expect one
ball and would want an error raised if we accidentally made two.
The if let Ok(...)
syntax is a Rust idiom that lets us execute the block and
bind the Result
of our get_single_mut
call to the mutable variable
transform
only if its Ok
.
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.
Lets start by spawning a paddle:
const PADDLE_SPEED: f32 = 1.;
const PADDLE_WIDTH: f32 = 10.;
const PADDLE_HEIGHT: f32 = 50.;
#[derive(Component)]
struct Paddle;
#[derive(Bundle)]
struct PaddleBundle {
paddle: Paddle,
position: Position,
}
impl PaddleBundle {
fn new(x: f32, y: f32) -> Self {
Self {
paddle: Paddle,
position: Position(Vec2::new(x, y)),
}
}
}
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning paddles...");
let mesh = Mesh::from(Rectangle::new(PADDLE_WIDTH, PADDLE_HEIGHT));
let material = ColorMaterial::from(Color::rgb(0., 1., 0.));
let mesh_handle = meshes.add(mesh);
let material_handle = materials.add(material);
commands.spawn((
PaddleBundle::new(20., -25),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
},
));
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (
spawn_ball,
spawn_camera,
spawn_paddles
))
.add_systems(Update, 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 XPBD 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.
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.
// 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)]
struct Velocity(Vec2);
#[derive(Bundle)]
struct BallBundle {
ball: Ball,
velocity: Velocity,
position: Position
}
impl BallBundle {
// Arguments set the initial velocity of the ball
fn new(x: f32, y: f32) -> Self {
Self {
ball: Ball,
velocity: Velocity(Vec2::new(x, y)),
position: Position(Vec2::new(0., 0.)),
}
}
}
fn move_ball(
mut ball: Query<(&mut Position, &Velocity),
With<Ball>>
) {
if let Ok((mut position, velocity)) = ball.get_single_mut() {
position.0 += velocity.0
}
}
fn project_positions(
mut ball: Query<(&mut Transform, &Position)>
) {
for (mut transform, position) in &mut ball {
transform.translation = position.0.extend(0.);
}
}
fn spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning ball...");
let mesh = Mesh::from(Circle::new(BALL_SIZE));
let material = ColorMaterial::from(Color::rgb(1., 0., 0.));
let mesh_handle = meshes.add(mesh);
let material_handle = materials.add(material);
commands.spawn((
// Moving to the right just like before
BallBundle::new(1., 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
},
));
}
Now our ball moves just like before but its deriving this movement from the
Velocity
component on the ball.
Interestingly our Query
in move_ball
has its query generic as a tuple. This
is how we ask for multiple components from Bevy even though the Query<Q, F>
only takes two generic arguments:
fn move_ball(
mut ball: Query<
(&mut Position, &Velocity), // The QueryData
With<Ball> // The QueryFilter
>
)
The Transform
translation 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
.
// Extend our `Vec2` (the `position.0`) into
// a `Vec3` with a `z` value of 0.
transform.translation = position.0.extend(0.);
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
).
Now that we have our velocity determining where we go next frame, our logic for our collision system becomes easy:
- 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,
BoundingCircle,
BoundingVolume,
IntersectsVolume
}
BoundingCircle
represents our ball as a Circle
with a specific position,
while Aabb2d
represents a bounding box for our gutters or paddles.
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 the `ball` hit.
fn collide_with_side(
ball: BoundingCircle,
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 Shape
that we create:
#[derive(Component)]
struct Shape(Vec2);
#[derive(Bundle)]
struct BallBundle {
ball: Ball,
shape: Shape,
velocity: Velocity,
position: Position,
}
impl BallBundle {
fn new(x: f32, y: f32) -> Self {
Self {
ball: Ball,
shape: Shape(Vec2::new(BALL_SIZE, BALL_SIZE)),
velocity: Velocity(Vec2::new(x, y)),
position: Position(Vec2::new(0., 0.)),
}
}
}
The same Shape
can represent our paddles:
#[derive(Bundle)]
struct PaddleBundle {
paddle: Paddle,
shape: Shape,
position: Position,
velocity: Velocity,
}
impl PaddleBundle {
fn new(x: f32, y: f32) -> Self {
Self {
paddle: Paddle,
shape: Shape(Vec2::new(PADDLE_WIDTH, PADDLE_HEIGHT)),
position: Position(Vec2::new(x, y)),
velocity: Velocity(Vec2::new(0., 0.)),
}
}
}
And we will need to update our systems which spawn these components:
fn spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning ball...");
let mesh = Mesh::from(Circle::new(BALL_SIZE));
let material = ColorMaterial::from(Color::rgb(1., 0., 0.));
// Now our mesh shape is derived from the `Shape`
// we made as a new component
let mesh_handle = meshes.add(mesh);
let material_handle = materials.add(material);
commands.spawn((
BallBundle::new(1., 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
},
));
}
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning paddles...");
let mesh = Mesh::from(Rectangle::new(PADDLE_WIDTH, PADDLE_HEIGHT));
let material = ColorMaterial::from(Color::rgb(0., 1., 0.));
let mesh_handle = meshes.add(mesh);
let material_handle = materials.add(material);
commands.spawn((
PaddleBundle::new(20., -25.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle,
..default()
},
));
}
Now that our balls have shapes as components (instead of the more complicated
handles) we can easily incorporate our collide_with_side
function into a new
system that will handle all the collisions for our game:
fn handle_collisions(
mut ball: Query<(&mut Velocity, &Position, &Shape), With<Ball>>,
other_things: Query<(&Position, &Shape), Without<Ball>>,
) {
if let Ok((
mut ball_velocity,
ball_position,
ball_shape
)) = ball.get_single_mut() {
for (position, shape) in &other_things {
if let Some(collision) = collide_with_side(
BoundingCircle::new(ball_position.0, ball_shape.0.x),
Aabb2d::new(
position.0,
shape.0 / 2.
)
) {
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.;
}
}
}
}
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(Startup, (spawn_ball, spawn_camera, spawn_paddles))
.add_systems(Update, (
move_ball,
project_positions.after(move_ball),
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
So far we just arbitrarily placed a paddle 25 pixels to the right of our ball.
In pong each paddle sits at the edge of the screen. We could eyeball our values and slowly iterate but where does the window size come from?
A Window
in Bevy is just a component on some entity representing our windows.
We can have more than one but for our purposes we can assume a single window.
This all gets added by the DefaultPlugins
which loads the WindowPlugin
.
The default Window
has a resolution of 1280x720. Our Camera2d
is a default
of centering the origin at (0, 0).
We set our ball’s initial position to (0, 0) and it spawns center of the screen which is our origin. We spawned a camera looking at this center from above in a top down view.
Therefore the far left of our screen is -(1280 / 2) and the right is (1280 / 2). So 640px from the origin on each side.
Because Window
is a component we can access it by using a Query
:
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Query<&Window>,
) {
println!("Spawning paddles...");
if let Ok(window) = window.get_single() {
let window_width = window.resolution.width();
let padding = 50.;
let right_paddle_x = window_width / 2. - padding;
let left_paddle_x = -window_width / 2. + padding;
let mesh = Mesh::from(Rectangle::new(
PADDLE_WIDTH,
PADDLE_HEIGHT,
));
let mesh_handle = meshes.add(mesh);
commands.spawn((
Player,
PaddleBundle::new(right_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.clone().into(),
material: materials.add(
ColorMaterial::from(Color::rgb(0., 1., 0.))
),
..default()
},
));
commands.spawn((
Ai,
PaddleBundle::new(left_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: materials.add(
ColorMaterial::from(Color::rgb(0., 0., 1.))
),
..default()
},
));
}
}
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.
const GUTTER_HEIGHT: f32 = 20.;
#[derive(Component)]
struct Gutter;
#[derive(Bundle)]
struct GutterBundle {
gutter: Gutter,
shape: Shape,
position: Position,
}
impl GutterBundle {
fn new(x: f32, y: f32, width: f32) -> Self {
Self {
gutter: Gutter,
shape: Shape(Vec2::new(width, GUTTER_HEIGHT)),
position: Position(Vec2::new(x, y)),
}
}
}
fn spawn_gutters(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Query<&Window>,
) {
if let Ok(window) = window.get_single() {
let window_width = window.resolution.width();
let window_height = window.resolution.height();
// We take half the window height because the center of our screen
// is (0, 0). The padding would be half the height of the gutter as its
// origin is also center rather than top left
let top_gutter_y = window_height / 2. - GUTTER_HEIGHT / 2.;
let bottom_gutter_y = -window_height / 2. + GUTTER_HEIGHT / 2.;
let top_gutter = GutterBundle::new(0., top_gutter_y, window_width);
let bottom_gutter = GutterBundle::new(0., bottom_gutter_y, window_width);
let mesh = Mesh::from(Rectangle::from_size(top_gutter.shape.0));
let material = ColorMaterial::from(Color::rgb(0., 0., 0.));
// We can share these meshes between our gutters by cloning them
let mesh_handle = meshes.add(mesh);
let material_handle = materials.add(material);
commands.spawn((
top_gutter,
MaterialMesh2dBundle {
mesh: mesh_handle.clone().into(),
material: material_handle.clone(),
..default()
},
));
commands.spawn((
bottom_gutter,
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: material_handle.clone(),
..default()
},
));
}
}
Now when we run our game we see a nicely fit border on the top and bottom for our black gutters.
We changed BallBundle::new(1., 0.)
to BallBundle::new(1., 1.)
so our ball
will move towards the top right and bounce off the top gutter.
Wow, 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.
Moving our paddle
Input is handled by Bevy and can be read by us with the Input
resource:
const PADDLE_SPEED: f32 = 5.;
fn handle_player_input(
keyboard_input: Res<ButtonInput<KeyCode>>,
mut paddle: Query<&mut Velocity, With<Player>>,
) {
if let Ok(mut velocity) = paddle.get_single_mut() {
if keyboard_input.pressed(KeyCode::ArrowUp) {
velocity.0.y = 1.;
} else if keyboard_input.pressed(KeyCode::ArrowDown) {
velocity.0.y = -1.;
} else {
velocity.0.y = 0.;
}
}
}
fn move_paddles(
mut paddle: Query<(&mut Position, &Velocity), With<Paddle>>,
window: Query<&Window>,
) {
if let Ok(window) = window.get_single() {
let window_height = window.resolution.height();
let max_y = window_height / 2. - GUTTER_HEIGHT - PADDLE_HEIGHT / 2.;
for (mut position, velocity) in &mut paddle {
let new_position = position.0 + velocity.0 * PADDLE_SPEED;
if new_position.y.abs() < max_y {
position.0 = new_position;
}
}
}
}
The keyboard_input.pressed
will return true during a frame where the keycode
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
. We also
restrict their movement to not exceed the bounds of the gutters we placed.
Scoring
For scoring we can demonstrate another great Bevy feature: events.
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.
We can ensure they both react to the score detection and keep the logic
separated by creating and emitting our own Event
:
enum Scorer {
Ai,
Player
}
#[derive(Event)]
struct Scored(Scorer);
#[derive(Resource, Default)]
struct Score {
player: u32,
ai: u32,
}
fn detect_scoring(
mut ball: Query<&mut Position, With<Ball>>,
window: Query<&Window>,
mut events: EventWriter<Scored>,
) {
if let Ok(window) = window.get_single() {
let window_width = window.resolution.width();
if let Ok(ball) = ball.get_single_mut() {
// Here we write the events using our EventWriter
if ball.0.x > window_width / 2. {
events.send(Scored(Scorer::Ai));
} else if ball.0.x < -window_width / 2. {
events.send(Scored(Scorer::Player));
}
}
}
}
fn reset_ball(
mut ball: Query<(&mut Position, &mut Velocity), With<Ball>>,
mut events: EventReader<Scored>,
) {
for event in events.read() {
if let Ok((
mut position,
mut velocity
)) = ball.get_single_mut() {
match event.0 {
Scorer::Ai => {
position.0 = Vec2::new(0., 0.);
velocity.0 = Vec2::new(-1., 1.);
}
Scorer::Player => {
position.0 = Vec2::new(0., 0.);
velocity.0 = Vec2::new(1., 1.);
}
}
}
}
}
fn update_score(
mut score: ResMut<Score>,
mut events: EventReader<Scored>
) {
for event in events.read() {
match event.0 {
Scorer::Ai => score.ai += 1,
Scorer::Player => score.player += 1,
}
}
println!("Score: {} - {}", score.player, score.ai);
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.init_resource::<Score>()
.add_event::<Scored>()
.add_systems(
Startup,
(
spawn_ball,
spawn_camera,
spawn_paddles,
spawn_gutters
),
)
.add_systems(
Update,
(
move_ball,
handle_player_input,
detect_scoring,
reset_ball.after(detect_scoring),
update_score.after(detect_scoring),
move_paddles.after(handle_player_input),
project_positions.after(move_ball),
handle_collisions.after(move_ball),
),
)
.run();
}
We add both our Scored
event and our Score
resource to our app
initialization so they will be available in our systems.
App::new()
.add_plugins(DefaultPlugins)
.init_resource::<Score>()
.add_event::<Scored>()
When we init_resource
it will use our Default
implementation, setting both
of the properties (each a u32
) to 0.
We write events using an EventWriter<T>
and then read events of the same type
with an EventReader<T>
.
events.send(Scored(Scorer::Ai));
These are double buffered queues of events. Double buffered means that it does not matter the order in which our systems run (before or after they are emitted) as each reader has access to both this ticks events and the last.
Displaying our score
Now that we have a score we need to display it on the screen.
#[derive(Component)]
struct PlayerScoreboard;
#[derive(Component)]
struct AiScoreboard;
fn update_scoreboard(
mut player_score: Query<
&mut Text,
With<PlayerScoreboard>
>,
mut ai_score: Query<
&mut Text,
(With<AiScoreboard>, Without<PlayerScoreboard>)
>,
score: Res<Score>,
) {
if score.is_changed() {
if let Ok(mut player_score) = player_score.get_single_mut() {
player_score.sections[0].value = score.player.to_string();
}
if let Ok(mut ai_score) = ai_score.get_single_mut() {
ai_score.sections[0].value = score.ai.to_string();
}
}
}
fn spawn_scoreboard(
mut commands: Commands,
) {
commands.spawn((
// Create a TextBundle that has a Text with a
// single section.
TextBundle::from_section(
// Accepts a `String` or any type that converts
// into a `String`, such as `&str`
"0",
TextStyle {
font_size: 72.0,
color: Color::WHITE,
..default()
},
) // Set the alignment of the Text
.with_text_justify(JustifyText::Center)
// Set the style of the TextBundle itself.
.with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(5.0),
right: Val::Px(15.0),
..default()
}),
PlayerScore
));
// Then we do it again for the AI score
commands.spawn((
TextBundle::from_section(
"0",
TextStyle {
font_size: 72.0,
color: Color::WHITE,
..default()
},
)
.with_text_justify(JustifyText::Center)
.with_style(Style {
position_type: PositionType::Absolute,
top: Val::Px(5.0),
left: Val::Px(15.0),
..default()
}),
AiScore
));
}
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
.
We use 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.
if score.is_changed() {
// Logic here only happens if the `Score`
// resource changed this frame
}
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.
#[derive(Component)]
struct Ai;
fn move_ai(
mut ai: Query<(&mut Velocity, &Position), With<Ai>>,
ball: Query<&Position, With<Ball>>,
) {
if let Ok((mut velocity, position)) = ai.get_single_mut() {
if let Ok(ball_position) = ball.get_single() {
let a_to_b = ball_position.0 - position.0;
velocity.0.y = a_to_b.y.signum();
}
}
}
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
window: Query<&Window>,
) {
println!("Spawning paddles...");
if let Ok(window) = window.get_single() {
let window_width = window.resolution.width();
let padding = 50.;
let right_paddle_x = window_width / 2. - padding;
let left_paddle_x = -window_width / 2. + padding;
let mesh = Mesh::from(shape::Quad::new(Vec2::new(
PADDLE_WIDTH,
PADDLE_HEIGHT,
)));
let mesh_handle = meshes.add(mesh);
commands.spawn((
Player,
PaddleBundle::new(right_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.clone().into(),
material: materials.add(
ColorMaterial::from(Color::rgb(0., 1., 0.))
),
..default()
},
));
commands.spawn((
// Adding the Ai component here so we can query
// for this specific paddle in our `move_ai` system
Ai,
PaddleBundle::new(left_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh_handle.into(),
material: materials.add(
ColorMaterial::from(Color::rgb(0., 0., 1.))
),
..default()
},
));
}
}
We also had to modify our spawn_paddles
system to add our new Ai
component.
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.