Tainted\\Coders

Bevy Tutorial: Pong

Bevy version: 0.17Last updated:

The goal of this tutorial is to build Pong using a minimal amount of dependencies for the purpose of learning Bevy.

This tutorial assumes you are complete beginner with only some basic Rust experience.

You can find the full source code to the project here.

Bevy is a data-driven game engine built in Rust. It uses an ergonomic entity component system (ECS) where your systems are plain rust functions.

Bevy in its current state 0.17 does not yet have an editor. However, if you're thinking of making a game that is code-first and less design heavy Bevy can be an amazing choice. Simulations work especially well.

Getting started

To start we create a new project with Cargo:

cargo new pong && cd pong

This puts us inside the new project folder pong which will have a Cargo.toml and src/main.rs.

Your Cargo.toml will look like this:

[package]
name = "pong"
version = "0.1.0"
edition = "2024"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

To use Bevy we need to add it as a dependency. We can either manually add it to the [dependencies] section of our Cargo.toml or use the cargo add command:

cargo add bevy

When we run this, the dependency gets added for us in the Cargo.toml under the [dependencies] section:

[dependencies]
bevy = "0.17"

Compiling our project

Rust code (the stuff we type) needs to be translated into machine code that can be executed by your CPU. Your source code gets compiled which will generate an executable binary. This binary will contain all the instructions ready to be executed by your operating system.

To compile and run our code we use cargo:

cargo build

This will download your dependencies, compile the src/main.rs, and build the executable in the target/debug folder at the root of your project.

The first time you run this, it will be quite slow. The external dependencies need to be downloaded and compiled by your system, and Bevy has quite a few.

But notice that when we compile again its nearly instant. This is because the dependencies have already been compiled, there is no need to start from square one. Cargo is smart enough to detect what needs to be recompiled.

This means that even though your initial compilation will be slow, as we build our game your compiler is only going to compile the things that have changed. So unless we update our Bevy version it won't compile your dependencies again.

What is interesting is that we never make use of our dependencies, yet they still get compiled. In an actual release this code would be pruned automatically during dead code elimination if we didn't use it anywhere.

Once it has been built we can run the executable:

cargo run

In fact, most of the time you will just be running cargo run which will also run cargo build.

Our src/main.rs currently just outputs "Hello, world!" to the console:

fn main() {
  println!("Hello, world!");
}

Creating an app

At its core, a game is really just a big loop where we draw the results of our game logic many times a second on the screen.

The App is the thing responsible for letting us specify exactly how this loop should work.

We can change the src/main.rs to the worlds most minimal game loop:

use bevy::prelude::*;

fn main() {
  App::new().run();
}

The first line use bevy::prelude::* is what lets us call App::new(). We are saying grab all the public constants, functions, etc from bevy::prelude and make them available at the top level of our own script.

The prelude is a Rust idiom where library authors can make the most commonly used elements of their libraries available from one place. Rather than many use statements each specifying a different part of the library to import.

Now, lets run our game:

cargo run

Which runs, but outputs absolutely nothing. Even our "Hello world" from before is gone. Not much of a game loop...

Your app ran your game logic, there just wasn't any. So it happily finished.

Adding a plugin

Plugins are Bevy's way of grouping up a bunch of functionality into something you can plug into your game.

Bevy includes DefaultPlugins in the bevy::prelude which gives us all of the obvious things our game would need:

PluginDescription
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
ScheduleRunnerPluginConfigures an App to run its Schedule according to a given RunMode
TaskPoolPluginSetup of default task pools for multithreading
TimePluginAdds time functionality
TransformPluginHandles Transform components
TypeRegistrationPluginRegisters types for reflection (dynamic lookup of types at runtime)

Then depending on the features you enable (all enabled by default) you have more plugins that are added:

PluginFeatureDescription
AccessibilityPluginbevy_windowAdds non-GUI accessibility functionality
AnimationPluginbevy_animationAdds animation support
AssetPluginbevy_assetAdds asset server and resources to load assets
AudioPluginbevy_audioAdds support for using sound assets
DevToolsPluginbevy_dev_toolsEnables developer tools in an App
CiTestingPluginbevy_ci_testingHelps instrument continuous integration
CorePipelinePluginbevy_core_pipelineThe core rendering pipeline
DebugAssetPlugindebug_asset_serverAdds stuff that helps debugging your assets
GltfPluginbevy_gltfAdds support for loading gltf models
GilrsPluginbevy_gilrsAdds support for gamepad inputs
GizmoPluginbevy_gizmosProvides an immediate mode drawing api for visual debugging
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)
WindowPluginbevy_windowProvides an interface to create and manage Window components
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 at some point in our game loop, we need to schedule it. We do this by adding a system.

Bevy has done a lot of work to make adding systems as ergonomic as possible. To us they look like plain Rust functions.

So lets define our first system:

fn hello_world() {
  println!("Hello world");
}

Then we need to tell our app to run this system in at a particular point in our schedule. We only want this to run once so we will give it a schedule label of Startup:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, hello_world)
    .run();
}

Now when we cargo run now we see "Hello world" in the console (as well as some other logging information about our app that came from the DefaultPlugins).

We scheduled the hello_world system to run during the Startup schedule.

Bevy uses schedules to group systems together and run them at a specific part of the main loop. The three most common are:

  1. Startup run once at the start of our game
  2. Update run every time we loop
  3. FixedUpdate run every X seconds

So lets change the schedule to Update:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Update, hello_world)
    .run();
}

Now "Hello world" outputs rapidly to the console, once for every loop our game goes through.

It is common for beginners to struggle whether to use Update or FixedUpdate. In general you should prefer FixedUpdate unless you absolutely need per-frame updates for your logic.

Creating a ball

Ok, enough printing to the console. Its time to make something more interesting.

Bevy uses an entity component system (ECS). We've already seen systems. Now its time to introduce entities and components.

Lets write a system to spawn a ball on the screen. Spawning something just means creating an entity in our game world.

fn spawn_ball(mut commands: Commands) {
  println!("Spawning ball...");
  commands.spawn_empty();
}

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, spawn_ball)
    .run();
}

We just spawned our first entity.

An Entity is really an ID. Kind of like a pointer we can use to find the components that are associated to it. Bevy is storing all our components in big arrays of the same type, and this entity is the index in each array that makes up that entity's data.

An astute reader might have noticed we never actually called our system ourselves, yet we still provided it an argument of Commands.

fn spawn_ball(mut commands: Commands) {
  // ...
}

This is because Bevy uses a technique is called dependency injection. The way Bevy accomplishes this is quite complicated. For us its enough to appreciate and thank them that this is so ergonomic.

Basically, so long as we use special parameters called system parameters, our regular rust functions can be easily turned into Bevy systems.

Operations that change the game world like spawning entities, or writing messages will mutate and therefore need exclusive access to your World. Bevy tries very hard to run your systems in parallel.

To avoid blocking the main loop, our systems will use the Commands interface to tell Bevy to add commands to a queue that will make the changes all at once in a later system.

commands.spawn_empty();

spawn_empty is a method on our Commands which will create a new Entity.

If this were a traditional database backed application, you can think of adding an entity just like adding a new row.

Even though we added an entity, we won't be able to see it for two reasons:

  1. We are missing a Camera to see anything at all
  2. There is nothing about our new entity that needs to be drawn on the screen

Entities themselves are not the things being rendered. Instead the components of an entity are what determine if Bevy draws them on the screen at all.

Creating a camera

First, lets solve the missing camera by creating one:

fn spawn_camera(mut commands: Commands) {
  commands
    .spawn_empty()
    .insert(Camera2d);
}

Here we start out the same way as above with a spawn_empty but then we chain on an insert and pass in a Camera2d component.

Inserting just means to associate the components to an Entity by placing it inside the array of other components at an index that matches that entity's index.

Components are stored in big arrays of the same type for performance reasons. We essentially added a new column and associated it to our row (the entity).

In fact, spawning entities and components together is so common that there is a much more convenient spawn method you can use to say the same thing:

fn spawn_camera(mut commands: Commands) {
  commands
    .spawn(Camera2d);
}

This is just like before, it will spawn a new entity and insert our component. Most of the time you will be using spawn unless you're adding to an existing entity.

For spawning many components at once on a new entity, we can give spawn a tuple:

fn spawn_camera(mut commands: Commands) {
  commands
    .spawn((
      Camera2d,
      Transform::from_xyz(0., 0., 0.)
    ));
}

The Camera2d component will also add any of the other required components it needs onto your entity if you have not added them yourself.

For example, lets look at an abbreviated definition of Camera2d:

#[derive(Component, Default)]
#[require(
  Camera,
  // ... more stuff here
)]
pub struct Camera2d;

You can see the require macro being used to specify the other components that have to be added to any entity with a Camera2d on it.

So when we add a Camera2d to our entity, we get the Camera::default() component added too.

The required components will not add any components that already exist, and you cannot have two components of the same type on any single entity. So if we added our own Camera component it would override the one given to us by Camera2d.

The #[require] macro comes from Bevy's new required components which is a major change introduced to the API in 0.15.

Finally we have to add this new system to the App definition:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, (spawn_ball, spawn_camera))
    .run();
}

Running our game now things have changed: Instead of a completely black window we get a lighter background. Something is actually rendering on the screen now because we have opened our eyes (by adding a camera) to the game world.

Drawing a ball

Bevy draws things on the screen every game loop through its pipelined renderer. We added this through the DefaultPlugins which added the RenderPlugin.

This plugin added a few systems responsible for taking certain components from our world, sending their data to the GPU and then drawing the results on the screen.

To make these systems draw something we need 3 things:

  1. A Transform to give a position on the screen
  2. A Mesh2d which provides the shape
  3. A MeshMaterial2d which defines how the renderer should paint the shape

Each of these is handled by a separate component that the systems from the RenderPlugins will iterate over.

Representing the ball in space

For positioning things Bevy has the built-in component Transform which is made up of 3 fields that you will recognize from other engines:

  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. This would be the same no matter how big your monitor was. Then we have our physical position which is where you are on the rendered window and would change depending on your display settings.

If we want the screen to be resizable we don't want to mix our logical position with the physical position of where it will be rendered on the screen.

A better alternative is to use a separate Position that we control ourselves, and then we can project that position onto the transform in a single separate system.

So we can start by representing our logical position with a new component:

#[derive(Component)]
struct Position(Vec2);

This is our first real component. We create them by using the Component macro (that #[derive(Component)] one line above).

Our Position component takes just a single positional field of a Vec2 which we use to represent our logical position inside our game world.

By creating a component, we essentially made a new optional column, following our database analogy, on any entity. When we insert this onto an entity we are filling in the value of that column for that particular row.

Now we need something to mark our entity is a ball, rather than a wall or a paddle.

#[derive(Component)]
struct Ball;

For this component, it's enough to just have it on our entity. It doesn't need any data. When you use a component in this way its called a marker component.

Now you might be thinking, why don't we just do this:

#[derive(Component)]
struct Ball {
  position: Position
}

The reason is so that we can treat positions generically. We will be writing a system that works over all things that have Position and we don't want to be searching through all these different components and their fields to find them.

Now, we also will want to make it easy for us to create our ball. And we could right now by assembling the pieces ourselves:

fn spawn_ball(mut commands: Commands) {
  commands
    .spawn((Position(Vec2::ZERO), Ball))
}

But what happens later in our game when we add balls in other systems? We would need to remember all the components that made up what it means to be a ball.

This is where required components come in handy:

#[derive(Component, Default)]
#[require(Transform)]
struct Position(Vec2);

#[derive(Component)]
#[require(Position)]
struct Ball

By adding a require macro to our ball we are telling Bevy that any entity with a Ball should also be spawned with a Position. So long as our Position has a default, it will add that default if we do not add our own.

We will need the Transform for Bevy's renderer to actually position our component on the screen. We add this to our logical position so that everything can be kept in sync by a separate system we will build.

Because our Ball requires Position and Position requires Transform, spawning a single Ball component gives us:

  1. Ball
  2. Position
  3. Transform

So with all this together we can add our systems:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, (spawn_ball, spawn_camera))
    .add_systems(FixedUpdate, project_positions)
    .run();
}

fn spawn_camera(mut commands: Commands) {
  commands.spawn(Camera2d);
}

fn spawn_ball(mut commands: Commands) {
  println!("Spawning ball...");

  commands.spawn(Ball)
}

fn project_positions(mut positionables: Query<(&mut Transform, &Position)>) {
  for (mut transform, position) in &mut positionables {
    transform.translation = position.0.extend(0.);
  }
}

Notice that even though we made a Position, we still need to add the Transform component so that Bevy knows where to place us. The RenderPlugins plugin is responsible for that are querying for these components and drawing them on the screen.

Our project_positions system is there to take our own Position and update Bevy's generic Transform to keep them in sync.

You of course don't have to do things this way. However, for explaining the inner workings of Bevy and in my own findings in game development I find it quite useful.

Lets take a closer look at the projection system:

fn project_positions(mut positionables: Query<(&mut Transform, &Position)>) {
  // Here we are iterating over the query to get the
  // components from our game world
  for (mut transform, position) in &mut positionables {
    // Extend is going to turn this from a Vec2 to a Vec3
    transform.translation = position.0.extend(0.);
  }
}

The Transform translation field is a Vec3. Even in 2D (assuming a top down view) it represents how "close" to us the object is. So we need to extend our Vec2 into a Vec3.

An alternative could have been to use a Velocity(Vec3) but its nicer to simplify our vector math in 2D and not worry about the z-index until we project it into 3D space (using Vec2::extend).

This is one of the key advantages of using our own Position as opposed to capitulating to the needs of Bevy's Transform.

We also introduced a new system parameter: Query. We gave it one generic argument: (&mut Transform, &Position)

Every Query<D, F> actually has two generic arguments:

  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 Mesh2d that will store all the vertices (points in space) that make up the shape we want.

Then for the texture that gets put onto our shape we will need a MeshMaterial2d that we give a Color::srgb which will tell Bevy's renderer to paint our shape one solid color.

When you add assets like a Mesh2d you add them to a resource specific to that asset type. So all Mesh2d are stored in your Assets<Mesh2d> for example. You will add them and Bevy will return you a Handle to that asset.

A Handle is just like an Entity. It's a unique ID for an asset we have loaded. So a Mesh2d is not actually asking us for the whole mesh. It really just wants the unique ID of that mesh that gets stored in an Assets<T>.

So there will be both a Assets<Mesh2d> and an Assets<MeshMaterial2d> already available to us as resources, which were provided by the AssetsPlugin (which we added with the DefaultPlugins).

Before we can pass them to our entity we need to add our Assets<T>.

const BALL_SHAPE: Circle = Circle::new(BALL_SIZE);
const BALL_COLOR: Color = Color::srgb(1., 0., 0.);

fn spawn_ball(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<ColorMaterial>>,
) {
  // `Assets::add` will load these into memory and return a `Handle` (an ID)
  // to these assets. When all references to this `Handle` are cleaned up
  // the asset is cleaned up.
  let mesh = meshes.add(BALL_SHAPE);
  let material = materials.add(BALL_COLOR);

  // Here we are using `spawn` instead of `spawn_empty` followed by an
  // `insert`. They mean the same thing, letting us spawn many components on a
  // new entity at once.
  commands.spawn((Ball, Mesh2d(mesh), MeshMaterial2d(material)));
}

This is the first time we are seeing a Resource. These are just like our components, but don't belong to a specific Entity. You can think about them like singleton components.

Instead of using a Query for these resources we can ask for them directly as a generic arguments to Res or ResMut.

When you see ResMut<Assets<Mesh>> you can think of it as saying:

Give me exclusive mutable access to the resource of type Assets<Mesh>

We actually load the asset by calling Assets<T>::add which does the work of adding our assets and returns us a Handle<T> for that type of asset.

// Both of these return a Handle<T> to the assets
// rather than the full data of the asset itself
let mesh = meshes.add(Circle::new(BALL_SIZE));
let material = materials.add(BALL_COLOR);

Now when we run our game we get a tiny red dot in the center of our game window. That's our ball!

Moving our ball

So not much of a game yet, but we could improve it by adding some dynamic movement.

Lets start with a simple goal: move our ball to the right steadily.

We could do so by changing our Position every loop to be slightly more to the right than it is currently.

fn move_ball(
  // Give me all positions that also contain a `Ball` component
  mut ball: Query<&mut Position, With<Ball>>,
) {
  if let Ok(mut position) = ball.single_mut() {
    position.0.x += 1.0
  }
}

We have a query that uses both generic arguments. Query<D, F>

The first one D is what we want returned. So we are asking for all the Position components.

The second F is a filter which is modifying our request to only get Position components from entities which also have a Ball. The upside of using the filter is that the Ball is not actually returned from the query. It is only changing which Position components get returned to us.

Now this could be fine, using single_mut to get access to the position. But given that we know there should only be one ball we can use the Single system parameter instead:

fn move_ball(mut position: Single<&mut Position, With<Ball>>) {
  position.0.x += 1.0;
}

Single is special version of a Query that will skip the system if none or more than one match of the query exists.

Now we need to schedule our system before we project the positions:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, (spawn_ball, spawn_camera))
    .add_systems(
      Update,
      (
        // Add our `move_ball` system to run before
        // we project our positions so we are not reading
        // movement one frame behind
        move_ball.before(project_positions),
        project_positions
      ),
    )
    .run();
}

We moved the ball by changing its Position every FixedUpdate. This modified position is then projected onto the Transform which will cause Bevy's RenderPlugins to update its physical position on the screen.

Running our game we can see our ball move steadily to the right each frame.

Bouncing off a paddle

Next up lets make some action. At minimum our game should have some interaction between its entities.

Once again we need to create the component and what it requires:

#[derive(Component)]
#[require(Position)]
struct Paddle;

Now we can make a similar system to spawn_ball but for our two paddles:

const PADDLE_SHAPE: Rectangle = Rectangle::new(20., 50.);
const PADDLE_COLOR: Color = Color::srgb(0., 1., 0.);

fn spawn_paddles(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<ColorMaterial>>,
) {
  let mesh = meshes.add(PADDLE_SHAPE);
  let material = materials.add(PADDLE_COLOR);

  commands.spawn((
    Paddle,
    Mesh2d(mesh),
    MeshMaterial2d(material),
    Position(Vec2::new(250., 0.)),
  ));
}

And we will schedule this system with our other Startup systems:


fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, (
      spawn_ball,
      spawn_camera,
      spawn_paddles
    ))
    .add_systems(
      FixedUpdate,
      (move_ball, project_positions.after(move_ball))
    )
    .run();
}

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

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

There are physics libraries like Rapier or Avian which get you started faster but for the purposes of this tutorial we will make everything ourselves.

Handling collisions

To bounce we will need to detect collisions and then change the movement of our ball.

Immediately our simple "move to the right" logic of our move_ball function will need to be reworked. If other systems need to set the movement of our ball then we will need something more dynamic.

Instead of our ball moving 1.0 to the right every frame it should move whatever the Velocity of our ball is.

const BALL_SPEED: f32 = 2.;

// This component is a tuple type, we can access the `Vec2` it holds
// by using the position of the item in the tuple
// e.g. velocity.0 which would be a `Vec2`
#[derive(Component, Default)]
struct Velocity(Vec2);

#[derive(Component)]
#[require(
  Position,
  Velocity = Velocity(Vec2::new(-BALL_SPEED, BALL_SPEED)),
)]
struct Ball;

We gave our ball a Velocity with a different default that wasn't 0 so that our ball actually moves when its first spawned.

Velocity here is just something we invented, a new component we will make that lets us set a direction we desire to move the next time we do. That way our movement system can just read this value and move us.

The alternative would be for many systems to move the ball which can get messy and impossible to only move a set distance each frame. The less each system knows about the whole game the better.

fn move_ball(ball: Single<(&mut Position, &Velocity), With<Ball>>) {
  let (mut position, velocity) = ball.into_inner();
  position.0 += velocity.0 * BALL_SPEED;
}

Now our ball moves just like before but its deriving this movement from the Velocity component on the ball.

We have our velocity determining where we go next frame, so the logic for our collision system becomes simple:

To build a simple collision system we can use some of the types from bevy_math, which is a convenient module holding some common operations:

use bevy::math::bounding::{
  Aabb2d,
  BoundingVolume,
  IntersectsVolume
};

Aabb2d represents a bounding box for our gutters, paddles or ball.

The other two types BoundingVolume and IntersectsVolume are traits that won't be used directly but are implemented in the first two types and will need to be in scope.

In a 2D game like Pong we can simplify things down to 4 types of collisions which we can represent as an enum:

#[derive(Debug, PartialEq, Eq, Copy, Clone)]
enum Collision {
  Left,
  Right,
  Top,
  Bottom,
}

With all this we have enough to build a simple function we can call in any system to check for collisions between our ball and another area:

// Returns `Some` if `ball` collides with `wall`. The returned `Collision` is the
// side of `wall` that `ball` hit.
fn collide_with_side(ball: Aabb2d, wall: Aabb2d) -> Option<Collision> {
  if !ball.intersects(&wall) {
    return None;
  }

  let closest_point = wall.closest_point(ball.center());
  let offset = ball.center() - closest_point;

  let side = if offset.x.abs() > offset.y.abs() {
    if offset.x < 0. {
      Collision::Left
    } else {
      Collision::Right
    }
  } else if offset.y > 0. {
    Collision::Top
  } else {
    Collision::Bottom
  };

  Some(side)
}

Up until now we have defined our size during the creation with the shape of the Mesh. But its going to be overly complicated to get our handle and constantly query our assets for the shape.

Once again it would be better for our mesh to derive from some kind of Collider that we create:

#[derive(Component, Default)]
struct Collider(Rectangle);

Then we can update our Ball definition with a default Collider through its required components:

#[derive(Component)]
#[require(
  Position,
  Velocity = Velocity(Vec2::new(-BALL_SPEED, BALL_SPEED)),
  Collider = Collider(Rectangle::new(BALL_SIZE, BALL_SIZE))
)]
struct Ball;

The same Collider can be used represent our paddles:

#[derive(Component)]
#[require(
  Position,
  Collider = Collider(PADDLE_SHAPE)
)]
struct Paddle;

Here we are giving the paddle a default collider. We can still override it but the default for a Collider will be a Vec2::ZERO so we give it a better default for our paddles.

Now that our balls have colliders as components we can easily incorporate our collide_with_side function into a new system that will handle all the collisions for our game:

impl Collider {
  fn half_size(&self) -> Vec2 {
    self.0.half_size
  }
}

fn handle_collisions(
  ball: Single<(&mut Velocity, &Position, &Collider), With<Ball>>,
  other_things: Query<(&Position, &Collider), Without<Ball>>,
) {
  let (mut ball_velocity, ball_position, ball_collider) = ball.into_inner();

  for (other_position, other_collider) in &other_things {
    if let Some(collision) = collide_with_side(
      Aabb2d::new(ball_position.0, ball_collider.half_size()),
      Aabb2d::new(other_position.0, other_collider.half_size()),
    ) {
      match collision {
        Collision::Left => {
          ball_velocity.0.x *= -1.;
        }
        Collision::Right => {
          ball_velocity.0.x *= -1.;
        }
        Collision::Top => {
          ball_velocity.0.y *= -1.;
        }
        Collision::Bottom => {
          ball_velocity.0.y *= -1.;
        }
      }
    }
  }
}

Then we need to add this system to our App. We will schedule it to run after the move_ball system.


fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(Startup, (spawn_ball, spawn_paddles, spawn_camera))
    .add_systems(
      FixedUpdate,
      (
        project_positions,
        move_ball.before(project_positions),
        handle_collisions.after(move_ball),
      ),
    )
    .run();
}

The collide_with_side function we created returns a Collision which is an enum that we match on.

Then we flip either the x or y components of our velocity depending on which side the collision happens on.

When we run our game the ball slides to the right, hits the paddle and bounces off just like we intended!

Spawning the other paddle

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

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

#[derive(Component)]
struct Player;

#[derive(Component)]
struct Ai;

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

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

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

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

  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).

So we can query the window to find its size and place ourselves accordingly. We will also want the paddles to be different colors to better identify our Player vs Ai.

fn spawn_paddles(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<ColorMaterial>>,
  window: Single<&Window>,
) {
  let mesh = meshes.add(PADDLE_SHAPE);
  let material = materials.add(PADDLE_COLOR);
  let half_window_size = window.resolution.size() / 2.;
  let padding = 20.;

  let player_position = Vec2::new(-half_window_size.x + padding, 0.);

  commands.spawn((
    Player,
    Paddle,
    Mesh2d(mesh.clone()),
    MeshMaterial2d(material.clone()),
    Position(player_position),
  ));

  let ai_position = Vec2::new(half_window_size.x - padding, 0.);

  commands.spawn((
    Ai,
    Paddle,
    Mesh2d(mesh.clone()),
    MeshMaterial2d(material.clone()),
    Position(ai_position),
  ));
}

Now our paddles are on either side of the screen and if we change how our window gets initialized or change the resolution before we spawn our paddles they will adjust automatically.

Creating the gutters

We need the ball to stay in bounds so we can add some vertical movement without it flying off the screen.

Adding our gutters looks a lot like adding our paddles but more concerned about the y axis than the x.

#[derive(Component)]
#[require(Position, Collider)]
struct Gutter;

const GUTTER_COLOR: Color = Color::srgb(0., 0., 1.);
const GUTTER_HEIGHT: f32 = 20.;

fn spawn_gutters(
  mut commands: Commands,
  mut meshes: ResMut<Assets<Mesh>>,
  mut materials: ResMut<Assets<ColorMaterial>>,
  window: Single<&Window>,
) {
  let material = materials.add(GUTTER_COLOR);
  let padding = 20.;

  let gutter_shape = Rectangle::new(window.resolution.width(), GUTTER_HEIGHT);
  let mesh = meshes.add(gutter_shape);

  let top_gutter_position =
    Vec2::new(0., window.resolution.height() / 2. - padding);

  commands.spawn((
    Gutter,
    Mesh2d(mesh.clone()),
    MeshMaterial2d(material.clone()),
    Position(top_gutter_position),
    Collider(gutter_shape)
  ));

  let bottom_gutter_position =
    Vec2::new(0., -window.resolution.height() / 2. + padding);

  commands.spawn((
    Gutter,
    Mesh2d(mesh.clone()),
    MeshMaterial2d(material.clone()),
    Position(bottom_gutter_position),
    Collider(gutter_shape)
  ));
}

Now when we run our game we see a nicely fit border on the top and bottom for our blue gutters.

Also, we never added any specific code for handing collisions with a gutter and yet our handle_collisions system still worked.

Because our gutters are made up of both a Shape and a Position the system checked for collisions with the ball on our newly placed gutters. There is no gutter specific movement required.

Moving our paddle

Input is handled by Bevy and can be read by us with the ButtonInput resource:

const PADDLE_SPEED: f32 = 5.;

fn handle_player_input(
  keyboard_input: Res<ButtonInput<KeyCode>>,
  mut paddle_velocity: Single<&mut Velocity, With<Player>>,
) {
  if keyboard_input.pressed(KeyCode::ArrowUp) {
    paddle_velocity.0.y = PADDLE_SPEED;
  } else if keyboard_input.pressed(KeyCode::ArrowDown) {
    paddle_velocity.0.y = -PADDLE_SPEED;
  } else {
    paddle_velocity.0.y = 0.;
  }
}

fn move_paddles(mut paddles: Query<(&mut Position, &Velocity), With<Paddle>>) {
  for (mut position, velocity) in &mut paddles {
    position.0 += velocity.0;
  }
}

The keyboard_input.pressed will return true during a frame where the key-code we pass it matches to a key pressed by us.

So when we press up we set our desired velocity upwards (+1.0), move_paddles will use this to move our paddles scaled to our PADDLE_SPEED.

When we run the game we can move our paddle outside of each of the gutters. There is nothing preventing us from colliding with them.

We can write a system to check if we collide with any of the gutters and to force our position inside the bounds when we do:

fn constrain_paddle_position(
  mut paddles: Query<
    (&mut Position, &Collider),
    (With<Paddle>, Without<Gutter>),
  >,
  gutters: Query<(&Position, &Collider), (With<Gutter>, Without<Paddle>)>,
) {
  for (mut paddle_position, paddle_collider) in &mut paddles {
    for (gutter_position, gutter_collider) in &gutters {
      let paddle_aabb =
        Aabb2d::new(paddle_position.0, paddle_collider.half_size());
      let gutter_aabb =
        Aabb2d::new(gutter_position.0, gutter_collider.half_size());

      if let Some(collision) = collide_with_side(paddle_aabb, gutter_aabb) {
        match collision {
          Collision::Top => {
            paddle_position.0.y = gutter_position.0.y
              + gutter_collider.half_size().y
              + paddle_collider.half_size().y;
          }
          Collision::Bottom => {
            paddle_position.0.y = gutter_position.0.y
              - gutter_collider.half_size().y
              - paddle_collider.half_size().y;
          }
          _ => {}
        }
      }
    }
  }
}

We are reusing the logic from our collide_with_side to determine which way we need to push the paddle (either up or down) to keep it from going outside of the gutters.

Then we need to schedule these systems inside our App:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .add_systems(
      Startup,
      (spawn_ball, spawn_paddles, spawn_camera, spawn_gutters),
    )
    .add_systems(
      FixedUpdate,
      (
        project_positions,
        move_ball.before(project_positions),
        handle_collisions.after(move_ball),
        move_paddles.before(project_positions),
        handle_player_input.before(move_paddles),
        constrain_paddle_position.after(move_paddles),
      ),
    )
    .run();
}

Scoring

We need to represent our score. There is only one score per game. It's a piece of data so it has to be some kind of Component.

This brings up a good question though: Which entity should hold the score?

In situations where you only want one piece of data to exist and it does not make sense to put on any single entity, then you have a great use case for a Resource instead.

Let's add our first resource to the game:

#[derive(Resource)]
struct Score {
  player: u32,
  ai: u32,
}

A Resource is just a component without an entity. They are singleton components we can easily access from our systems.

After we define a resource we need to add it to our app's definition with insert_resource. This will make it available when our game starts.

fn main() {
  App::new()
      .add_plugins(DefaultPlugins)
      .insert_resource(Score { player: 0, ai: 0 })
      // ...
}

When someone scores in pong a couple of things have to happen:

  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.

In Bevy, there are two ways to communicate between our systems:

  1. Messages for publish/subscribe style communication within a couple frames
  2. Events for immediately triggering other systems this frame

We will use events here which will immediately trigger both our systems when someone scores.

#[derive(EntityEvent)]
struct Scored {
  entity: Entity,
}

There are two kinds of events:

  1. Event for global events
  2. EntityEvent for events relating to a specific entity

Here we are making an EntityEvent which expects a struct that contains at least an entity field. You can rename this by setting the #[event_target] on the field that holds an Entity.

#[derive(EntityEvent)]
struct Scored {
  #[event_target]
  scorer: Entity,
}

Then we need a system that detects a goal and fires our event:

fn detect_goal(
  ball: Single<(&Position, &Collider), With<Ball>>,
  player: Single<Entity, (With<Player>, Without<Ai>)>,
  ai: Single<Entity, (With<Ai>, Without<Player>)>,
  window: Single<&Window>,
  mut commands: Commands,
) {
  let (ball_position, ball_collider) = ball.into_inner();
  let half_window_size = window.resolution.size() / 2.;

  if ball_position.0.x - ball_collider.half_size().x > half_window_size.x {
    commands.trigger(Scored { scorer: *player });
  }

  if ball_position.0.x + ball_collider.half_size().x < -half_window_size.x {
    commands.trigger(Scored { scorer: *ai });
  }
}

Finally we need the two systems that will react to these events:

fn reset_ball(
  _event: On<Scored>,
  ball: Single<(&mut Position, &mut Velocity), With<Ball>>,
) {
  let (mut ball_position, mut ball_velocity) = ball.into_inner();
  ball_position.0 = Vec2::ZERO;
  ball_velocity.0 = Vec2::new(BALL_SPEED, 0.);
}

fn update_score(
  event: On<Scored>,
  mut score: ResMut<Score>,
  is_ai: Query<&Ai>,
  is_player: Query<&Player>,
) {
  if is_ai.get(event.scorer).is_ok() {
    score.ai += 1;
    info!("AI scored! {} - {}", score.player, score.ai);
  }

  if is_player.get(event.scorer).is_ok() {
    score.player += 1;
    info!("Player scored! {} - {}", score.player, score.ai);
  }
}

These systems are special because of their first system parameter: On. This makes them observers which are callbacks that respond to certain events.

With these two systems we can add them to our app definition. But we don't add them with the usual add_systems call. Instead we add them as global observers with add_observer:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .insert_resource(Score { player: 0, ai: 0 })
    .add_systems(
      Startup,
      (spawn_ball, spawn_paddles, spawn_camera, spawn_gutters),
    )
    .add_systems(
      FixedUpdate,
      (
        project_positions,
        move_ball.before(project_positions),
        handle_collisions.after(move_ball),
        move_paddles.before(project_positions),
        handle_player_input.before(move_paddles),
        constrain_paddle_position.after(move_paddles),
        detect_goal.after(move_ball),
      ),
    )
    // Here we are adding our observer systems as global observers
    .add_observer(reset_ball)
    .add_observer(update_score)
    .run();
}

Displaying our score

Now that we have a score we need to display it on the screen.

To do so we need to build some UI entities that can hold the components that store each score:

#[derive(Component)]
struct PlayerScore;

#[derive(Component)]
struct AiScore;

Then we can add these components to some entities which will render themselves in the UI layer of our game.

To do this we create entities which have a Node component on them. These Node components determine the layout within the window, outside of your game's world positions.

fn spawn_scoreboard(mut commands: Commands) {
  // Create a container that will center everything
  let container = Node {
    width: percent(100.0),
    height: percent(100.0),
    justify_content: JustifyContent::Center,
    ..default()
  };

  // Then add a container for the text
  let header = Node {
    width: px(200.),
    height: px(100.),
    ..default()
  };

  // The players score on the left hand side
  let player_score = (
    PlayerScore,
    Text::new("0"),
    TextFont::from_font_size(72.0),
    TextColor(Color::WHITE),
    TextLayout::new_with_justify(Justify::Center),
    Node {
      position_type: PositionType::Absolute,
      top: px(5.0),
      left: px(25.0),
      ..default()
    },
  );

  // The AI score on the right hand side
  let ai_score = (
    AiScore,
    Text::new("0"),
    TextFont::from_font_size(72.0),
    TextColor(Color::WHITE),
    TextLayout::new_with_justify(Justify::Center),
    Node {
      position_type: PositionType::Absolute,
      top: px(5.0),
      right: px(25.0),
      ..default()
    },
  );

  commands.spawn((
    container,
    children![(header, children![player_score, ai_score])],
  ));
}

We spawned a player and AI scoreboard which has a Text component that will get rendered to the screen with systems added from the TextPlugin. Then we overrode its required components to give it the style and position we need.

The children! macro is our first time creating a relationship in Bevy. In this case we are using the builtin Children/ChildOf relationship. This lets an entity have many Children and each child contains a ChildOf(Entity) pointing to the parent.

Bevy keeps the Children in sync with each of the ChildOf components so we can easily access them from either direction.

If something is a ChildOf another entity, it will keep its Transform in sync and relative to that parent. This is how these Node components can be placed relatively to one another.

Next up, we need to keep the score in sync:

fn update_scoreboard(
  mut player_score: Single<&mut Text, (With<PlayerScore>, Without<AiScore>)>,
  mut ai_score: Single<&mut Text, (With<AiScore>, Without<PlayerScore>)>,
  score: Res<Score>,
) {
  if score.is_changed() {
    player_score.0 = score.player.to_string();
    ai_score.0 = score.ai.to_string();
  }
}

Here we are using the Score resource we created and ask if it is_changed which uses Bevy's change detection features to only run our logic on frames where the scores value has changed.

Programming the AI player

To program the computer controlled player we can do some simple vector math to get our desired Velocity for the left paddle.

Subtracting two vectors that represent coordinates in our game will give us a new vector pointing from one to the other.

We can then use this new vector's y component to set our desired movement.

fn move_ai(
  ai: Single<(&mut Velocity, &Position), With<Ai>>,
  ball: Single<&Position, With<Ball>>,
) {
  let (mut velocity, position) = ai.into_inner();
  let a_to_b = ball.0 - position.0;
  velocity.0.y = a_to_b.y.signum() * PADDLE_SPEED;
}

Its very common to have lots of marker components to allow you to query specific parts of your game.

Congrats, running your game now you can play against your opponent. Try tweaking the constant values for speed of the ball and paddles to try and make it fun.

Finishing up and next steps

The final app definition should look something like this:

fn main() {
  App::new()
    .add_plugins(DefaultPlugins)
    .insert_resource(Score { player: 0, ai: 0 })
    .add_systems(
      Startup,
      (
        spawn_ball,
        spawn_paddles,
        spawn_camera,
        spawn_gutters,
        spawn_scoreboard,
      ),
    )
    .add_systems(
      FixedUpdate,
      (
        project_positions,
        move_ball.before(project_positions),
        handle_collisions.after(move_ball),
        move_paddles.before(project_positions),
        handle_player_input.before(move_paddles),
        constrain_paddle_position.after(move_paddles),
        detect_goal.after(move_ball),
        update_scoreboard,
        move_ai,
      ),
    )
    .add_observer(reset_ball)
    .add_observer(update_score)
    .run();
}

We learned how to build a classic game from scratch using Bevy. This tutorial was designed with care to be an easy introduction for Rust beginners to get something fun on the screen to play around with.

In a more realistic setting you would be using a lot more ecosystem crates to handle the functionality we built from scratch here. Each of them uses similar techniques, but without you having to reinvent the wheel.

A great next step would be to take what we have built and add more serious components in such as:

If you're looking for a grand tour of the ecosystem you can check out the Tainted Coder's Awesome Bevy List.