Tainted\\Coders

Bevy Tutorial: Pong

Bevy version: 0.14Last 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.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:

PluginDescription
AccessibilityPluginAdds non-GUI accessibility functionality
DiagnosticsPluginAdds core diagnostics
FrameCountPluginAdds frame counting functionality
HierarchyPluginHandles Parent and Children components
InputPluginAdds keyboard and mouse input
LogPluginAdds logging to apps
PanicHandlerPluginAdds sensible panic handling
TaskPoolPluginSetup of default task pools for multithreading
TimePluginAdds time functionality
TransformPluginHandles Transform components
TypeRegistrationPluginRegisters types for reflection (dynamic lookup of types at runtime)
WindowPluginProvides 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:

PluginFeatureDescription
AnimationPluginbevy_animationAdds animation support
AssetPluginbevy_assetAdds asset server and resources to load assets
AudioPluginbevy_audioAdds support for using sound assets
CiTestingPluginbevy_ci_testingHelps instrument continuous integration
CorePipelinePluginbevy_core_pipelineThe core rendering pipeline
DebugAssetPlugindebug_asset_serverAdds stuff that helps debugging your assets
GizmoPluginbevy_gizmosProvides an immediate mode drawing api for visual debugging
GilrsPluginbevy_gilrsAdds support for gamepad inputs
GltfPluginbevy_gltfAllows loading gltf based assets
ImagePluginbevy_renderAdds the Image asset and prepares them to render on your GPU
PbrPluginbevy_pbrAdds physical based rendering with StandardMaterial etc
PipelinedRenderingPluginbevy_renderEnables pipelined rendering which makes rendering multithreaded
RenderPluginbevy_renderSets up rendering backend powered by wgpu crate
ScenePluginbevy_sceneLoading and saving collections of entities and components to files
SpritePluginbevy_spriteHandling of sprites (images on our entities)
TextPluginbevy_textSupports loading fonts and rendering text
UiPluginbevy_uiAdds support for UI layouts (flex, grid, etc)
WinitPluginbevy_winitInterface 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:

  1. Startup run once at the start of our game
  2. Update 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:

  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:

// 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:

  1. A position on the screen
  2. Some kind of shape
  3. 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:

  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, 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:

  1. QueryData: The components we want returned
  2. 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 and Position components for entities that have a Transform AND Position 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:

  1. Mesh: the transparent 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 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_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,
        project_positions.after(move_ball)
      )
    )
    .run();
}

Running our game now we can see the ball moves to the right and then... Goes right through our paddle.

There is nothing in our game logic that says that balls and paddles care about each other. In other engines like Godot or Unity there is usually some kind of default physics but in Bevy you start from scratch.

There are physics libraries like Rapier or 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.)),
    }
  }
}

const BALL_SPEED: f32 = 5.;

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 * BALL_SPEED
  }
}

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:

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

All we have done so far is placed a paddle 25 pixels to the right of our ball.

When we add a second paddle, we will need to start knowing which one is the Player and which is the Ai. So lets add two marker components:

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Ai;

These components will let us query for each paddle separately later.

Each paddle should sit at the edge of the screen. We could eyeball our values and slowly iterate but where does the window size come from?

Bevy adds a Window component to an entity that is representing our real rendered window. This gets added by the DefaultPlugins which loads the WindowPlugin.

This means we can figure out where to place the paddles:

  1. The default Window has a resolution of 1280x720.
  2. Our Camera2d has its nose pointed directly at the origin (0, 0).
  3. If we set our ball's initial position to (0, 0) then it spawns center of the screen (our camera's origin).
  4. Therefore, the far left of our screen is -(1280 / 2) and the right is (1280 / 2).

We will also want the paddles to be different colors to better identify our Player vs Ai.

fn spawn_paddles(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<ColorMaterial>>,
  window: 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);
    let player_color = materials.add(Color::srgb(0., 1., 0.));
    let ai_color = materials.add(Color::srgb(0., 0., 1.));

    commands.spawn((
      Player,
      PaddleBundle::new(right_paddle_x, 0.),
      MaterialMesh2dBundle {
        mesh: mesh.clone().into(),
        material: player_color,
        ..default()
      },
    ));

    commands.spawn((
      Ai,
      PaddleBundle::new(left_paddle_x, 0.),
      MaterialMesh2dBundle {
        mesh: mesh.into(),
        material: ai_color,
        ..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:

  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 PlayerScore;

#[derive(Component)]
struct AiScore;

fn update_scoreboard(
  mut player_score: Query<
    &mut Text,
    With<PlayerScore>
  >,
  mut ai_score: Query<
    &mut Text,
    (With<AiScore>, 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.