[music] paused
| 100%
0:00 0:00
Music For Programming — musicforprogramming.net (not affiliated).
[subscribe]
sponsor on github

Learn the Basics of Bevy 0.19 by Building and Deploying Pong to Itch.io

Read this series in order: learning-bevy-by-building-projects

  1. Learn Bevy States, Timers, and Grid Movement by Building Snake
  2. Building Breakout in Bevy: Step by Step
  3. Learn the Basics of Bevy 0.19 by Building and Deploying Pong to Itch.io (current)

I'm starting a new series on learning game dev and the Bevy game engine. In each article we build one game and learn different concepts along the way. All projects come from the 20 Games Challenge.

This is a learning series for me as well. I'm familiar with game dev but not with Bevy, this is why I'm writing these primarily for learning and sharing my knowledge with you. If there is any incorrect information, then please inform me, I'll rectify that asap.

The only prerequisite is that you know Rust (or can learn it on the go). I also have a Rust learning series that follows the same project-based approach.

In this post, we build Pong, the classic arcade game where two players bounce a ball past each other's paddles and publish it on Itch.io. If you know Rust but have never built a game, this is the perfect place to start.

This is a rewrite of the original article in this series, updated for Bevy 0.19 and rewritten in an incremental style. Every section ends with cargo run , you'll see progress on screen after each step.

We'll learn: what a game loop is, how ECS (Entity-Component-System) works, how to detect collisions, how to handle keyboard input, and how to display text on screen.

We might not follow standard patterns or best practices in our learning series as the goal is to build games incrementally. For example, in this article, we are not using any third party physics crate, instead we are implementing a manual AABB collision algorithm, which we won't do in future articles.

Get the full source code from GitHub. Published game is on itch.io.

What Is a Game Engine?

Before writing code, let's understand what a game engine actually does.

At its core, a video game is just a loop. Every frame (usually 60 times per second), the game:

  1. Reads input - what keys are the players pressing?
  2. Updates the world - move paddles, move the ball, check for collisions.
  3. Renders - draw everything on screen.

This is the game loop. Without an engine, you'd write this loop yourself, read input, update state, draw pixels directly. An engine like Bevy handles graphics, audio, windowing, and input so you focus on your game.

Bevy organizes game code using ECS: Entity-Component-System. Let's understand each part:

  • Entity: A unique ID that represents something in your game. A paddle, the ball, the camera.
  • Component: Data attached to an entity. Position, velocity, color. Plain Rust structs with no methods.
  • System: A function that runs every frame and operates on entities that have specific components. A "movement" system finds all entities with a Position and Velocity and updates their positions.

Data and logic are separate. Components just sit there holding data. Systems read and modify components. This is different from object-oriented programming where objects contain both data and methods.

Project Setup

Open your terminal and create a new Rust project:

cargo new bevy_pong
cd bevy_pong

Open Cargo.toml and replace its contents with:

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

[dependencies]
bevy = "0.19"

The [dependencies] section tells Cargo what external libraries to download and compile. bevy = "0.19" pulls in the Bevy engine at version 0.19.

Run:

cargo build

This takes a few minutes. Bevy is a large engine, it compiles the renderer (OpenGL/Metal/Vulkan), audio system, asset loader, input handler, and more. Subsequent builds will be fast because only your code changes need recompilation.

1. A Window on Screen

Let's write the smallest possible Bevy program. Open src/main.rs and type this:

use bevy::prelude::*;
use bevy::window::WindowResolution;

const WINDOW_WIDTH: f32 = 800.0;
const WINDOW_HEIGHT: f32 = 600.0;

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Pong".into(),
                resolution: WindowResolution::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32),
                ..default()
            }),
            ..default()
        }))
        .insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.0)))
        .add_systems(Startup, setup)
        .run();
}

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

The main function - your app's headquarters

App::new() creates a new, empty Bevy application. Think of App as your game's headquarters, it holds everything: the world (all entities and their components), the schedule (which systems run and when), and the plugins (bundled features).

.add_plugins(DefaultPlugins) is the most important line in any Bevy project. DefaultPlugins is a bundle of everything Bevy needs to function:

  • WindowPlugin: Creates a window on your screen.
  • RenderPlugin: Renders graphics using your GPU.
  • AssetPlugin: Loads files like images and sounds.
  • InputPlugin: Handles keyboard, mouse, and gamepad input.
  • AudioPlugin: Plays sounds.

We use .set() to replace the default WindowPlugin with our own. This lets us control the window resolution (800×600) and title ("Pong").

WindowResolution::new(WINDOW_WIDTH, WINDOW_HEIGHT) takes a width and height in logical pixels. On high-DPI displays, the actual physical pixels might be different, but Bevy handles that conversion for you.

.insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.0))) sets the background color. ClearColor is a resource, a singleton piece of data. Unlike components attached to entities, there's exactly one instance of it in the world. Color::srgb(0.0, 0.0, 0.0) is pure black (no red, no green, no blue).

.add_systems(Startup, setup) registers our setup function as a system that runs during the Startup schedule. A schedule determines when a system runs. Bevy has a few key schedules:

  • Startup: Runs once when the app starts. Use this for creating entities, loading assets, setting up initial state.
  • Update: Runs every frame. Use this for gameplay logic like movement, collision, input handling.

.run() starts the game loop. This function never returns, it runs until the player closes the window.

The setup function - creating entities

commands: Commands is a queue of changes to make to the game world. When you call commands.spawn(...), the entity is queued and created at a safe point during the frame. You never create entities directly, always go through Commands.

commands.spawn(Camera2d) creates a new entity with a Camera2d component. In Bevy, components can require other components. Camera2d automatically brings along Camera (the camera settings), Projection (the math that converts coordinates), and Frustum (for culling off-screen objects). Without a camera, there's nothing to render through.

Understanding coordinates

Before we place objects, let's understand how positions work in a 2D game.

Your window is a flat rectangle. Every position on it is described by an (x, y) coordinate:

  • x: Horizontal position. 0 is the center. Negative is left. Positive is right.
  • y: Vertical position. 0 is the center. Negative is down. Positive is up.

Coordinates are measured in pixels. If your window is 800 pixels wide, the left edge is at x = -400, the right edge is at x = 400, and the center is at x = 0.

Each entity has a Transform component that stores its position, rotation, and scale. Transform::from_xyz(x, y, z) positions the entity at that coordinate. The z component controls which objects appear on top of others, higher z means closer to the camera. In a 2D game, we leave z at 0 for everything.

Run it:

cargo run

You should see a black 800×600 window titled "Pong". Nothing else yet, but Bevy is set up correctly and compiles without errors.

2. Two Paddles

Now we add the paddles that players control.

Creating our game objects

In ECS, data lives in components. Let's define what a paddle is:

const PADDLE_WIDTH: f32 = 10.0;
const PADDLE_HEIGHT: f32 = 100.0;
const PADDLE_SPEED: f32 = 500.0;
const PADDLE_OFFSET: f32 = 50.0;

#[derive(Component)]
struct Paddle {
    speed: f32,
    side: Side,
}

#[derive(Component)]
enum Side {
    Left,
    Right,
}

#[derive(Component)] is a derive macro that tells Bevy "this struct can be used as a component on an entity." It implements the Component trait, which provides metadata Bevy needs to manage the data.

Paddle { speed: f32, side: Side }, each paddle has a movement speed (pixels per second) and a side identifier (which player controls it). We store this data in the component so each paddle knows which keys to listen to.

Side is an enum, in Bevy, enums can be components too. We use it to determine which keyboard keys move which paddle.

Spawning the paddles

Now replace setup() with a version that spawns both paddles:

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

    // Left paddle
    commands.spawn((
        Paddle { speed: PADDLE_SPEED, side: Side::Left },
        Sprite::from_color(Color::WHITE, Vec2::new(PADDLE_WIDTH, PADDLE_HEIGHT)),
        Transform::from_xyz(-WINDOW_WIDTH / 2.0 + PADDLE_OFFSET, 0.0, 0.0),
    ));

    // Right paddle
    commands.spawn((
        Paddle { speed: PADDLE_SPEED, side: Side::Right },
        Sprite::from_color(Color::WHITE, Vec2::new(PADDLE_WIDTH, PADDLE_HEIGHT)),
        Transform::from_xyz(WINDOW_WIDTH / 2.0 - PADDLE_OFFSET, 0.0, 0.0),
    ));
}

Each commands.spawn((...)) call creates an entity with multiple components at once. The components are passed as a tuple, Bevy treats each element as a separate component and attaches all of them to the same entity.

Sprite::from_color(Color::WHITE, Vec2::new(10.0, 100.0)) creates a visual rectangle without loading any image files. The first argument is the fill color (white), the second is the size in pixels (10 wide, 100 tall).

Transform::from_xyz(-WINDOW_WIDTH / 2.0 + PADDLE_OFFSET, 0.0, 0.0) positions the left paddle. Our window is 800 pixels wide, so WINDOW_WIDTH / 2.0 = 400. The paddle sits at x = -400 + 50 = -350 (- 50 pixels from the left edge). The right paddle is at the mirror position.

Making the paddles move

Now we write our first real gameplay system, a function that reads keyboard input and moves the paddle:

fn move_paddle(
    keyboard: Res<ButtonInput<KeyCode>>,
    mut paddle_query: Query<(&mut Transform, &Paddle)>,
    time: Res<Time>,
) {
    for (mut transform, paddle) in &mut paddle_query {
        let mut direction = 0.0;

        match paddle.side {
            Side::Left => {
                if keyboard.pressed(KeyCode::KeyW) { direction = 1.0; }
                if keyboard.pressed(KeyCode::KeyS) { direction = -1.0; }
            }
            Side::Right => {
                if keyboard.pressed(KeyCode::ArrowUp) { direction = 1.0; }
                if keyboard.pressed(KeyCode::ArrowDown) { direction = -1.0; }
            }
        }

        transform.translation.y += direction * paddle.speed * time.delta_secs();

        // Clamp to window bounds
        let half_paddle = PADDLE_HEIGHT / 2.0;
        let half_height = WINDOW_HEIGHT / 2.0;
        transform.translation.y = transform.translation.y.clamp(
            -half_height + half_paddle,
            half_height - half_paddle,
        );
    }
}

System parameters - how Bevy feeds your functions

A Bevy system is just a Rust function. What makes it special is the parameters, Bevy automatically injects the right data based on the types in your function signature.

keyboard: Res<ButtonInput<KeyCode>>, Res<T> gives read-only access to a resource. ButtonInput<KeyCode> is Bevy's keyboard input resource. It tracks which keys are currently held down, which were just pressed this frame, and which were just released. We use .pressed(KeyCode::KeyW) to check if the W key is being held down right now.

mut paddle_query: Query<(&mut Transform, &Paddle)>, Query is how you find entities in the world. The type parameter (&mut Transform, &Paddle) means "give me mutable access to Transform and read-only access to Paddle, but only for entities that have BOTH of these components." Since both paddles have Transform and Paddle, this system processes both of them. The mut keyword means we can modify the components we asked for mutably.

time: Res<Time> - the Time resource provides timing information. We use delta_secs(), the time in seconds since the last frame. This is crucial for smooth movement.

Why delta time matters

Here's the problem every new game developer encounters. On a 60 Hz monitor, your game runs 60 times per second. On a 144 Hz monitor, it runs 144 times per second. If you moved the paddle 10 pixels every frame:

  • At 60 FPS: 600 pixels per second
  • At 144 FPS: 1440 pixels per second

The game runs at a completely different speed on different monitors. This is called a frame rate dependency bug.

The fix is to multiply your movement by delta_secs():

  • At 60 FPS: delta = 0.0167 seconds. 500 × 0.0167 = 8.35 pixels per frame.
  • At 144 FPS: delta = 0.0069 seconds. 500 × 0.0069 = 3.47 pixels per frame.

The per-frame movement is different, but over one second: 500 pixels in both cases. The game runs at the same speed regardless of monitor refresh rate.

Direction logic

We check which side this paddle is on and which keys are held. direction is 1.0 for up (positive y), -1.0 for down (negative y), and 0.0 if no keys are pressed.

We use two separate if statements (not if-else) because both keys could be pressed simultaneously. If W and S are both held, direction gets set to 1.0 then immediately to -1.0, the last key check wins. Not ideal (pressing both should cancel to zero), but functional enough for our first game.

Clamping

The clamp call keeps the paddle from going off the top or bottom of the screen. We calculate how far the paddle can travel: from -half_height + half_paddle (bottom edge + half paddle height) to half_height - half_paddle (top edge - half paddle height). This ensures the paddle's edge never crosses the window edge.

Register the system in main():

fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(WindowPlugin {
            primary_window: Some(Window {
                title: "Pong".into(),
                resolution: WindowResolution::new(WINDOW_WIDTH as u32, WINDOW_HEIGHT as u32),
                ..default()
            }),
            ..default()
        }))
        .insert_resource(ClearColor(Color::srgb(0.0, 0.0, 0.0)))
        .add_systems(Startup, setup)
        .add_systems(Update, move_paddle)
        .run();
}

Run it:

cargo run

Two white rectangles appear near the left and right edges. Move the left paddle with W/S and the right paddle with Arrow Up/Down. The paddles stop at the window edges.

3. The Ball

A Pong game needs a ball. Add the Ball component and the ball movement system:

const BALL_SIZE: f32 = 10.0;

#[derive(Component)]
struct Ball {
    velocity: Vec3,
}

The ball's velocity is a 3D vector. Even in a 2D game, we use Vec3 because Transform positions also use Vec3. The z component stays 0. The velocity determines how fast and in what direction the ball travels each frame.

Now add the ball to setup(), right after the paddle spawns:

    // Ball
    commands.spawn((
        Ball { velocity: Vec3::new(300.0, 150.0, 0.0) },
        Sprite::from_color(Color::WHITE, Vec2::splat(BALL_SIZE)),
        Transform::from_xyz(0.0, 0.0, 0.0),
    ));

Vec2::splat(BALL_SIZE) creates a Vec2(10.0, 10.0). The ball starts at the exact center of the screen. Its velocity is 300 pixels/second to the right and 150 pixels/second upward. This diagonal path gives it an interesting angle.

Add the movement system:

fn move_ball(
    mut ball_query: Query<(&mut Transform, &Ball)>,
    time: Res<Time>,
) {
    for (mut transform, ball) in &mut ball_query {
        transform.translation += ball.velocity * time.delta_secs();
    }
}

The ball movement follows the exact same pattern as the paddle: add velocity multiplied by delta time to position. The difference is there's no input logic, the ball just moves according to its stored velocity.

Register move_ball alongside move_paddle:

        .add_systems(Update, (move_paddle, move_ball))

Using a tuple registers multiple systems in the same schedule. They run in the order listed.

Run it:

cargo run

The ball flies diagonally off-screen. That's expected, we haven't added walls yet.

4. Walls and Bouncing

The ball should bounce off the top and bottom of the window. Add this system:

fn bounce_ball(mut ball_query: Query<(&mut Transform, &mut Ball)>) {
    let half_height = WINDOW_HEIGHT / 2.0;
    let half_ball = BALL_SIZE / 2.0;

    for (mut transform, mut ball) in &mut ball_query {
        // Top wall
        if transform.translation.y + half_ball >= half_height {
            transform.translation.y = half_height - half_ball;
            ball.velocity.y = -ball.velocity.y;
        }
        // Bottom wall
        if transform.translation.y - half_ball <= -half_height {
            transform.translation.y = -half_height + half_ball;
            ball.velocity.y = -ball.velocity.y;
        }
    }
}

The collision check

half_height = WINDOW_HEIGHT / 2.0 - our window is 600 pixels tall, so the center is at y = 0, the top edge is at y = 300, and the bottom edge is at y = -300.

half_ball = BALL_SIZE / 2.0 - our ball is 10×10 pixels, so its half-size (or "radius" for a square) is 5 pixels.

transform.translation.y + half_ball >= half_height checks if the ball's top edge (center y + half its height) has reached or passed the top of the window. If the ball center is at y = 297 and we add the radius of 5, we get 302, which is ≥ 300. Collision detected.

The snap

When a collision happens, we do two things:

transform.translation.y = half_height - half_ball;
ball.velocity.y = -ball.velocity.y;

First, we snap the ball back inside the play area. We set its y position to half_height - half_ball = 300 - 5 = 295. This prevents the ball from getting stuck outside the wall.

Why would it get stuck? Imagine the ball is fast (say 600 pixels per second) and the frame rate drops to 30 FPS. The ball moves 20 pixels in one frame. If it was at y = 290 and moved 20 pixels up, it'd be at y = 310. Next frame, it's still past the wall, so it bounces again, reversing direction again. But it's still past the wall because it bounced back only 10 pixels. It vibrates in place. Snapping ensures this never happens.

Second, we reverse the Y velocity. If the ball was moving upward (+150), it now moves downward (-150).

The bottom wall check is the mirror: transform.translation.y - half_ball <= -half_height.

Register it in main():

        .add_systems(Update, (move_paddle, move_ball, bounce_ball))

Run it:

cargo run

The ball bounces off the top and bottom edges. It still passes through the paddles

5. Paddle Collision

Now we make the ball bounce off both paddles using AABB collision detection (Axis-Aligned Bounding Box). Two rectangles overlap if they are not separated along any axis.

Add this system:

fn check_paddle_collision(
    mut ball_query: Query<(&mut Transform, &mut Ball), Without<Paddle>>,
    paddle_query: Query<(&Transform, &Paddle), Without<Ball>>,
) {
    for (mut ball_transform, mut ball) in &mut ball_query {
        let ball_pos = ball_transform.translation.truncate();
        let ball_size = Vec2::splat(BALL_SIZE);

        for (paddle_transform, _paddle) in &paddle_query {
            let paddle_pos = paddle_transform.translation.truncate();
            let paddle_size = Vec2::new(PADDLE_WIDTH, PADDLE_HEIGHT);

            // AABB overlap check
            let overlap = !(
                ball_pos.x + ball_size.x / 2.0 < paddle_pos.x - paddle_size.x / 2.0
                || ball_pos.x - ball_size.x / 2.0 > paddle_pos.x + paddle_size.x / 2.0
                || ball_pos.y + ball_size.y / 2.0 < paddle_pos.y - paddle_size.y / 2.0
                || ball_pos.y - ball_size.y / 2.0 > paddle_pos.y + paddle_size.y / 2.0
            );

            if overlap {
                ball.velocity.x = -ball.velocity.x;

                // Snap ball outside paddle to prevent sticking
                if ball.velocity.x > 0.0 {
                    ball_transform.translation.x =
                        paddle_pos.x + paddle_size.x / 2.0 + ball_size.x / 2.0;
                } else {
                    ball_transform.translation.x =
                        paddle_pos.x - paddle_size.x / 2.0 - ball_size.x / 2.0;
                }
            }
        }
    }
}

Disjoint queries - preventing data races

mut ball_query: Query<(&mut Transform, &mut Ball), Without<Paddle>>,
paddle_query: Query<(&Transform, &Paddle), Without<Ball>>,

Both queries use Without<T> filters. Without<Paddle> on the ball query means "only match entities that don't have a Paddle component." Without<Ball> on the paddle query means the opposite.

Why? Because Bevy needs to prove that these two queries never access the same entity. If they could, we'd have a data race, one query mutably accessing Transform while another query also accesses it. The Without filters tell Bevy "these are completely separate groups of entities," so it can run them safely without runtime checks.

How AABB collision works

Imagine two rectangles on screen. They overlap if they are not separated along any axis. A rectangle is separated from another if:

  1. Its right edge is to the left of the other's left edge.
  2. Its left edge is to the right of the other's right edge.
  3. Its top edge is below the other's bottom edge.
  4. Its bottom edge is above the other's top edge.

If any one of these is true, they don't overlap. If all four are false, they overlap.

Our check:

let overlap = !(
    ball_pos.x + ball_size.x / 2.0 < paddle_pos.x - paddle_size.x / 2.0
    || ball_pos.x - ball_size.x / 2.0 > paddle_pos.x + paddle_size.x / 2.0
    || ball_pos.y + ball_size.y / 2.0 < paddle_pos.y - paddle_size.y / 2.0
    || ball_pos.y - ball_size.y / 2.0 > paddle_pos.y + paddle_size.y / 2.0
);

ball_pos.x + ball_size.x / 2.0 is the ball's right edge. paddle_pos.x - paddle_size.x / 2.0 is the paddle's left edge. If the ball's right edge is to the left of the paddle's left edge, they're separated horizontally. We OR all four checks together and NOT the result, if none of the four separation conditions are true, the rectangles overlap.

Collision response

When a collision happens, we reverse the ball's X direction. Then we snap the ball to the outside of the paddle. If the ball is moving right (velocity.x > 0 after reversal is positive), it hit the left side of the paddle, so we place it just to the right of the paddle's right edge. If moving left, we place it just to the left of the paddle's left edge.

The snap prevents the same problem as wall bouncing, without it, the ball can get stuck inside the paddle, bouncing back and forth every frame until it wiggles through.

Register it:

        .add_systems(Update, (move_paddle, move_ball, bounce_ball, check_paddle_collision))

Run it:

cargo run

The ball bounces off both paddles and the top/bottom walls. Two-player Pong is playable.

6. Scoring

A game needs a score. We need a resource to store it and text to display it.

Score resource and text marker

Add these above setup():

#[derive(Resource, Default)]
struct Score {
    left: u32,
    right: u32,
}

#[derive(Component)]
struct ScoreText;

Score is a resource - a singleton piece of data. Unlike components attached to entities, resources are global. There's only one score in the entire game. #[derive(Default)] initializes both fields to 0.

We call it insert_resource instead of spawn because resources are not entities. They're singletons that any system can access. Entities are for things that exist in the game world (paddles, ball). Resources are for game-wide state (score, settings).

ScoreText is an empty marker component. We attach it to the text entity so we can find it later with a query. Without this, we'd have no way to distinguish the score text from other text entities.

Spawning the score display

Now add the score display to setup(), right after the camera spawn:

    use bevy::text::FontSize;

    commands.insert_resource(Score::default());

    commands.spawn((
        Text2d::new("0 - 0"),
        TextFont {
            font_size: FontSize::Px(40.0),
            ..default()
        },
        TextColor(Color::WHITE),
        Transform::from_xyz(0.0, WINDOW_HEIGHT / 2.0 - 50.0, 0.0),
        ScoreText,
    ));

Text2d::new("0 - 0") creates text positioned in the 2D world. It wraps a String. The initial text shows "0 - 0".

TextFont { font_size: FontSize::Px(40.0), ..default() } controls the font style. We set the size to 40 pixels and use the default font (Fira Mono, bundled with Bevy).

Bevy 0.19 note: TextFont::font_size now uses FontSize::Px(40.0) instead of a bare 40.0. This is one of the few breaking changes from 0.18.

Transform::from_xyz(0.0, WINDOW_HEIGHT / 2.0 - 50.0, 0.0) positions the text near the top of the screen. Our window is 600 pixels tall, so y = 300 is the top edge. We place it at y = 300 - 50 = 250, centered horizontally.

Add the use bevy::text::FontSize; import at the very top of the file alongside the other imports:

use bevy::prelude::*;
use bevy::text::FontSize;
use bevy::window::WindowResolution;

The scoring system

Now add the system that detects when a player scores:

fn score_goal(
    mut ball_query: Query<&mut Transform, With<Ball>>,
    mut score: ResMut<Score>,
    mut score_text: Query<&mut Text2d, With<ScoreText>>,
) {
    let half_width = WINDOW_WIDTH / 2.0;

    for mut transform in &mut ball_query {
        let scored = if transform.translation.x > half_width + 10.0 {
            score.left += 1;
            true
        } else if transform.translation.x < -half_width - 10.0 {
            score.right += 1;
            true
        } else {
            false
        };

        if !scored {
            continue;
        }

        transform.translation = Vec3::new(0.0, 0.0, 0.0);

        for mut text in &mut score_text {
            text.0 = format!("{} - {}", score.left, score.right);
        }
    }
}

ResMut<Score> - ResMut gives mutable access to a resource. We need to modify the score counters.

Query<&mut Transform, With<Ball>> - we only want the ball entity, so we filter for entities that have a Ball component.

Query<&mut Text2d, With<ScoreText>> - finds the text entity by its marker component.

If the ball goes past the right edge (plus a 10-pixel buffer so it fully disappears), the left player scores, the right player failed to return it. If it goes past the left edge, the right player scores.

After scoring, we reset the ball to the center. The ball keeps its current velocity, it doesn't restart from zero speed.

text.0 accesses the inner String of the Text2d tuple struct. We update it with the current score.

Register score_goal in main():

        .add_systems(
            Update,
            (move_paddle, move_ball, bounce_ball, check_paddle_collision, score_goal),
        )

Run it:

cargo run

Play a game. Each time a player misses, the score at the top updates and the ball resets to center.

Deploying to Itch.io

Let's build a web version and publish it so anyone can play in their browser.

Install the wasm target:

rustup target add wasm32-unknown-unknown

Install the Bevy CLI:

cargo install bevy_cli

Build the web release:

bevy build --release web

After it finishes, the generated files are in target/wasm32-unknown-unknown/web-release/. You should see:

bevy_pong.js
bevy_pong.wasm
bevy_pong_bg.wasm

Create an index.html file in that directory:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Bevy Pong</title>
  <style>
    body { margin: 0; overflow: hidden; background: black; }
    canvas { width: 100vw; height: 100vh; display: block; }
  </style>
</head>
<body>
  <script type="module">
    import init from "./bevy_pong.js";
    init();
  </script>
</body>
</html>

Create a clean deployment folder and copy only what itch.io needs:

mkdir itch_build
cp target/wasm32-unknown-unknown/web-release/bevy_pong.js itch_build/
cp target/wasm32-unknown-unknown/web-release/bevy_pong.wasm itch_build/
cp target/wasm32-unknown-unknown/web-release/bevy_pong_bg.wasm itch_build/
cp target/wasm32-unknown-unknown/web-release/index.html itch_build/
cp -r target/wasm32-unknown-unknown/web-release/assets itch_build/

Zip it:

cd itch_build
zip -r bevy_pong.zip .

Upload bevy_pong.zip to itch.io as a new project. Under "Kind of project", select HTML. Enable "This file will be played in the browser".

Things We Could Improve

  • Randomize the ball direction after each score (right now it keeps the same velocity).
  • Add sound effects for paddle hits, wall bounces, and scoring.
  • Add a particle effect on collision for game feel.
  • Add a win condition (first to 5, for example) with a game over screen.

What We Learned

This was a long one. I can go on and add other concepts as well but its already too long for our first article in this series. We have tons of chances to learn and reinforce our knowledge in future projects, so lets not worry about it. I hope you understood the overall basic concepts of Bevy and don't worry all these concepts will solidify in future with more and more projects.

In the next post, we'll build Snake and learn about game states, timers, and grid-based movement.

See you in the next one.