Bevy Tutorial: Pong
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.14
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.14"
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 includes DefaultPlugins
in the bevy::prelude
which gives us all of the obvious things our game would need:
Plugin | Description |
---|---|
AccessibilityPlugin | Adds non-GUI accessibility functionality |
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 |
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) |
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 |
---|---|---|
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 |
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 |
GizmoPlugin | bevy_gizmos | Provides an immediate mode drawing api for visual debugging |
GilrsPlugin | bevy_gilrs | Adds support for gamepad inputs |
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) |
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 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 look like plain Rust functions.
So lets define our first 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();
}
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 two most common are:
Startup
run once at the start of our gameUpdate
run every time we loop
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.
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 just an ID. Kind of like a pointer we can use to find the components that are associated to it.
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.
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:
// https://docs.rs/bevy/latest/bevy/core_pipeline/core_2d/struct.Camera2dBundle.html
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,
pub main_texture_usages: CameraMainTextureUsages,
}
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, its important to note that 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
by placing it inside the array of other components at an index that matches that entity's index.
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 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 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:
- A position on the screen
- Some kind of shape
- A texture (otherwise it would be transparent)
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. Then we have our physical position which is where you are on the rendered 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);
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.
Now we need something to specify our thing 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 components 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_empty()
.insert(Position(Vec2::ZERO))
.insert(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 bundles come in handy:
#[derive(Bundle)]
struct BallBundle {
ball: Ball,
position: Position
}
impl BallBundle {
fn new() -> Self {
Self {
ball: Ball,
position: Position(Vec2::new(0., 0.)),
}
}
}
A bundle is like a component, but when it's inserted it adds its components and then ceases to exist.
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(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(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 match.
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 one of our systems:
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 {
transform.translation = position.0.extend(0.);
}
}
We introduced a new system parameter: Query
. We gave it one generic argument: (&mut Transform, &Position)
Every query 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 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 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 Color::Sgrb
which will tell Bevy's renderer to 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 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
. It's a unique ID for an asset we have loaded. 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 we need to add our assets.
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 = Circle::new(BALL_SIZE);
let color = Color::srgb(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 = meshes.add(shape);
let material = 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(),
MaterialMesh2dBundle {
mesh: mesh.into(),
material,
..default()
},
));
}
This is the first time we are seeing a Resource
, which are just like our components, but don't belong to a specific Entity
.
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.
let mesh = meshes.add(shape);
let material = 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.into(),
material,
..default()
}
Why didn't we move MaterialMesh2dBundle
into our BallBundle
?
For the exact same reason we didn't add our Transform
to the BallBundle
, 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.
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.get_single_mut() {
position.0.x += 1.0
}
}
fn project_positions(
// Give me all transforms and positions from entities
// that contain both
mut positionables: Query<(&mut Transform, &Position)>
) {
// Our position is `Vec2` but a translation is `Vec3`
// so we extend our `Vec2` into one by adding a `z`
// value of 0
for (mut transform, position) in &mut positionables {
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 RenderPlugins
to update its physical position on 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 position) = 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.
Once again we need to create some components and a bundle:
#[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)),
}
}
}
Now we can make a similar system to spawn_ball
but for our two paddles:
const PADDLE_SPEED: f32 = 1.;
const PADDLE_WIDTH: f32 = 10.;
const PADDLE_HEIGHT: f32 = 50.;
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning paddles...");
let shape = Rectangle::new(PADDLE_WIDTH, PADDLE_HEIGHT);
let color = Color::srgb(0., 1., 0.);
let mesh = meshes.add(shape);
let material = materials.add(color);
commands.spawn((
PaddleBundle::new(20., -25),
MaterialMesh2dBundle {
mesh: mesh.into(),
material,
..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.
// 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
}
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.
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 spawn_ball(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning ball...");
let shape = Circle::new(BALL_SIZE);
let color = Color::srgb(1., 0., 0.);
let mesh = meshes.add(shape);
let material = materials.add(color);
commands.spawn((
// Moving to the right just like before
BallBundle::new(1., 0.),
MaterialMesh2dBundle {
mesh: mesh.into(),
material,
..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 our first QueryFilter
:
fn move_ball(
mut ball: Query<
(&mut Position, &Velocity), // The QueryData
With<Ball> // The QueryFilter
>
)
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
.
// 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
).
This is one of the key advantages of using our own Position
as opposed to capitulating to the needs of Bevy's Transform
.
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:
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 shape = Circle::new(BALL_SIZE);
let color = Color::srgb(1., 0., 0.);
// Now our mesh shape is derived from the `Shape`
// we made as a new component
let mesh = meshes.add(shape);
let material = materials.add(color);
commands.spawn((
BallBundle::new(1., 0.),
MaterialMesh2dBundle {
mesh: mesh.into(),
material,
..default()
},
));
}
fn spawn_paddles(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
) {
println!("Spawning paddles...");
let shape = Rectangle::new(PADDLE_WIDTH, PADDLE_HEIGHT);
let color = Color::srgb(0., 1., 0.);
let mesh = meshes.add(shape);
let material = materials.add(color);
commands.spawn((
PaddleBundle::new(20., -25.),
MaterialMesh2dBundle {
mesh: mesh.into(),
material,
..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 shape = Rectangle::new(
PADDLE_WIDTH,
PADDLE_HEIGHT,
);
let mesh = meshes.add(shape);
commands.spawn((
Player,
PaddleBundle::new(right_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh.clone().into(),
material: materials.add(Color::srgb(0., 1., 0.)),
..default()
},
));
commands.spawn((
Ai,
PaddleBundle::new(left_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh.into(),
material: materials.add(Color::srgb(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 shape = Rectangle::from_size(top_gutter.shape.0);
let color = Color::srgb(0., 0., 0.);
// We can share these meshes between our gutters by cloning them
let mesh = meshes.add(shape);
let material = materials.add(color);
commands.spawn((
top_gutter,
MaterialMesh2dBundle {
mesh: mesh.clone().into(),
material: material.clone(),
..default()
},
));
commands.spawn((
bottom_gutter,
MaterialMesh2dBundle {
mesh: mesh.into(),
material: material.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. There is no gutter specific movement required.
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 PlayerScore;
#[derive(Component)]
struct AiScoreboard;
fn update_scoreboard(
mut player_score: Query<
&mut Text,
With<PlayerScore>
>,
mut ai_score: Query<
&mut Text,
(With<AiScoreboard>, Without<PlayerScore>)
>,
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 shape = Rectangle::new(PADDLE_WIDTH, PADDLE_HEIGHT);
let mesh = meshes.add(shape);
commands.spawn((
Player,
PaddleBundle::new(right_paddle_x, 0.),
MaterialMesh2dBundle {
mesh: mesh.clone().into(),
material: materials.add(Color::srgb(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.into(),
material: materials.add(Color::srgb(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.