Tainted \\ Coders

Bevy Tutorial: Pong

Last updated:

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:

  1. We are missing a camera
  2. 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:

  1. It needs to be positioned somewhere on the screen
  2. It needs to have some kind of shape
  3. 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:

  1. Translation: its coordinates in the game
  2. Rotation: where it is pointing
  3. 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:

  1. QueryData: The components we want returned
  2. QueryFilter: An optional filter to only fetch components from entities that satisfy it

We basically told bevy:

Fetch us all the Transform and Position components for entities have a Transform AND Position 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:

  1. Mesh: the transparent 3d shape of our object
  2. 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:

  1. Reset the ball’s position and velocity
  2. 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.