Adding systems

Build a Flappy Bird Clone in Rust and Bevy 0.14Part 3 - Adding systems

Game
Bevy 0.14
Flappy Bird
Last update:

Bird Animation Bird Animation

In the previous part of the tutorial, we learned how to spawn the game entities, transform their positions, give them marker components, and render them on the screen.

However, the objects on the screen don't move. They are static and can't be interacted with. There needs to be some kind of interaction between the player and the game entities. This is where systems come into play.

In this part, we will learn how to add systems to our game.

If you remember when we added the setup system in the previous part to the Bevy App in the main.rs file, we used the method add_systems with the first argument being the schedule which was Startup that makes the function only run at startup.

This time however, we need to run the function at every frame. To do this, instead of Startup, we use Update as the first argument.

Let's start by adding a simple system that makes the Press Space Bar text blink, instead of being static.

Let's create a new file called systems.rs in the src directory, feel free to have your own directory structure if you want:

src
├── components.rs
├── main.rs
├── setup.rs
├── systems.rs
├── plugin.rs
├── constants.rs
└── utils.rs

Update main.rs:

use bevy::prelude::*;
use plugin::MyPlugin;
use setup::setup;
use systems::*;
 
mod components;
mod constants;
mod plugin;
mod setup;
mod systems;
mod utils;
 
fn main() {
  ...
}

Text Blinking System

In order to create the blinking animation, we need a way to calculate the time, to know how many seconds or milliseconds have passed, without knowing the time, we wouldn't be able to know when to hide the text and when to show it.

We need a mechanism to calculate the time every-time the system runs on Update (which is in very small fractions of a second), and only to show/hide the text when a certain amount of time has passed.

To calculate the time, we can use the Time struct which is available in the prelude, so we don't need to import it.

Since it's a resource, we need to wrap it in a Res struct, and since we do not need to mutate it, we don't use ResMut.

// systems.rs
use bevy::prelude::*;
 
pub fn blink_space_bar_text(time: Res<Time>) {}

With the Time struct, we can get the amount of time that has passed since the last frame using the delta() method.

Let's log the delta to the console using the dbg! macro:

pub fn blink_space_bar_text(time: Res<Time>) {
    dbg!(time.delta());
}

And, don't forget to add it to the entry point of the game in main.rs and set the schedule to Update:

// main.rs
fn main() {
    App::new()
        .add_systems(Startup, setup)
        .add_systems(Update, blink_space_bar_text)
        .add_plugins(MyPlugin)
        .run();
}

Running the code:

Time Delta Time Delta

The code runs roughly every 16 milliseconds, which is the time it takes to render a frame at 60 frames per second.

It's great that we can track the time, but there should also be a timer and only if that timer was finished, we should change the state of the text, otherwise we shouldn't do anything.

Bevy provides us with the Timer struct, with the conjunction the Time, we can find out when the timer has finished.

But, first we need to add the timer to the Entity we created in the previous part, we need to go back there and add the timer to the entity, so that we can use it here in the system.

In your components.rs file, update the PressSpaceBarText struct to include the Timer:

#[derive(Component)]
pub struct PressSpaceBarText(pub Timer);

In the setup.rs file, add the timer to the PressSpaceBarText entity:

...
    // Space Bar Text
    commands.spawn((
        SpriteBundle {
            texture: asset_server.load("texture/space.png"),
            transform: Transform::from_xyz(0.0, -50.0, 1.0),
            ..default()
        },
        PressSpaceBarText(Timer::from_seconds(0.5, TimerMode::Repeating)),
    ));
...

The timer is set to 0.5 seconds, and the mode is set to Repeating, which means that the timer will repeat every 0.5 seconds.

Now, in the system, we need a mechanism to get mutable access to both PressSpaceBarText and Visibility components, so we can change the state of the timer and show/hide the text.

In order to do that, we need to first understand how to use Query in Bevy.

Learn Rust by Practice

Master Rust through hands-on coding exercises and real-world examples.

Understanding Query in Bevy

In Bevy, the Query is a powerful tool to get the components of the entities that match the query. It's a way to get the components of the entities that we are interested in.

The Query is a generic that takes in two parameters, the first one is the components you want to get, and the second one is an optional filter that you can use to filter the entities.

Each parameter can be a tuple or it can just be a single component.

To get each component, you either have to get immutable or mutable access to the component, specified by & or &mut respectively.

The second parameter is used to filter the entities, you will not be able to have access to the components you specify there, if you want to have access, you need to pass them in the first parameter.

In our case, we want to get mutable access to the PressSpaceBarText and Visibility components, so we pass them in the first parameter.

use bevy::prelude::*;
 
use crate::components::PressSpaceBarText;
 
pub fn blink_space_bar_text(
    time: Res<Time>,
    mut query: Query<(&mut PressSpaceBarText, &mut Visibility)>,
) {
 
}

We don't need to use the filter parameter, because we only have one entity with the PressSpaceBarText component and we can guarantee that there is only one entity with the PressSpaceBarText component.

Great! Now we have mutable access to both visibility and timer states. We can tick the timer on every Update and change the visibility of the text based on the timer.

Since, we only have one entity with the PressSpaceBarText component, we can use the single_mut method on the query to get the mutable reference to the entity.

The single_mut() method gives us a mutable reference to the components, but it panics if there are more than one entity coming back from the query, so make sure you only use it when you can guarantee that there is only one entity matching the query.

pub fn blink_space_bar_text(
    time: Res<Time>,
    mut query: Query<(&mut PressSpaceBarText, &mut Visibility)>,
) {
    let (mut space, mut visibility) = query.single_mut();
}

Tick the timer by time.delta():

pub fn blink_space_bar_text(
    time: Res<Time>,
    mut query: Query<(&mut PressSpaceBarText, &mut Visibility)>,
) {
    let (mut space, mut visibility) = query.single_mut();
 
    let timer = &mut space.0;
    timer.tick(time.delta());
}

This will add the delta time which is the time that has passed since the last run of the system, and updates the timer.

Checking if the Timer is Finished

Now, we need to check if the timer is finished, if it is, we need to change the visibility of the text.

pub fn blink_space_bar_text(
    time: Res<Time>,
    mut query: Query<(&mut PressSpaceBarText, &mut Visibility)>,
) {
    let (mut space, mut visibility) = query.single_mut();
 
    let timer = &mut space.0;
    timer.tick(time.delta());
 
    if timer.finished() {
 
    }
}

The finished() method returns true if the timer has finished with the summation of all the tick() calls, reached the duration that we first defined in the setup.rs file, which was 0.5 seconds.

If the timer is finished, we need to change the visibility of the text, if it's visible, we need to hide it, and if it's hidden, we need to show it.

pub fn blink_space_bar_text(
    time: Res<Time>,
    mut query: Query<(&mut PressSpaceBarText, &mut Visibility)>,
) {
    let (mut space, mut visibility) = query.single_mut();
 
    let timer = &mut space.0;
    timer.tick(time.delta());
 
    if timer.finished() {
        if *visibility == Visibility::Hidden {
            *visibility = Visibility::Visible;
        } else {
            *visibility = Visibility::Hidden;
        }
    }
}

Use the dereference operator * to modify the visibility of the text.

Perfect! If you run the code, you'll see that the text is blinking every 0.5 seconds.

Moving the background

It's important to give the player a sense of movement in the game, to do that, we need to move the background to the left when the game starts.

Let's query the background's transform component and move it to the left by a certain amount every frame.

In the systems.rs file, add a new system called move_background:

// systems.rs
 
pub fn move_background(time: Res<Time>, mut query: Query<&mut Transform, With<Background>>) {
    let mut background_transform = query.single_mut();
    let delta = time.delta().as_secs_f32();
    let delta_x = 20. * delta;
 
    background_transform.translation.x -= delta_x;
}

Since we don't need to access any data on the Background struct (it's just a marker component), we only use it in the query filter to get the Transform component of the background.

Update main.rs file:

// main.rs
 
fn main() {
    App::new()
        .add_systems(Startup, setup)
        .add_systems(Update, blink_space_bar_text)
        .add_systems(Update, move_background)
        .add_plugins(MyPlugin)
        .run();
}

Running the code, you'll see that the background is moving to the left.

Moving Background Moving Background

As you can see, the background is moving to the left and it's moving outside the screen.

To fix that, there's a few things we need to do:

  • Make the background width larger so that we don't see the gap.
  • Reset the background position to the initial position when it moves outside the screen by background texture width * 2.

The texture width is 288px, so when the background moves by 288px, we need to reset the position of the background to the normal position.

The reason we need to multiply the texture width by 2 is because when we increase the width of the background, we increase 288px / 2 on the left and 288px / 2 on the right, that means there will still be a gap of 288px / 2 on the right side, to fix that, we need to multiply the texture width by 2.

// setup.rs
// Spawn the background
 
commands.spawn((
    SpriteBundle {
        texture: asset_server.load("texture/background.png"),
        sprite: Sprite {
            custom_size: Some(Vec2::new(WINDOW_WIDTH + 288.0 * 2., WINDOW_HEIGHT)), // Adding a custom size
            ..default() // Everything else is set to default
        },
        ..default()
    },
    ImageScaleMode::Tiled {
        tile_x: true,
        tile_y: false,
        stretch_value: 1.,
    },
    Background,
));

Resetting the position of the background:

// systems.rs
 
pub fn move_background(time: Res<Time>, mut query: Query<&mut Transform, With<Background>>) {
    let mut background_transform = query.single_mut();
    let delta = time.delta().as_secs_f32();
    let delta_x = 20. * delta;
 
    background_transform.translation.x -= delta_x;
 
    if background_transform.translation.x < -288.0 {
        background_transform.translation.x = 0.;
    }
}

If we run the code this time, we'll see that the background is moving to the left and when it moves outside the screen, it resets to the initial position. This gives us the feeling that the background is moving infinitely, but in actuality, it's just resetting the position.

Moving the ground

Let's add some movement to the base as well but with a different speed from the background. Since the ground is closer to the camera perspective, it should move faster than the background.

The code will be quite similar to the background movement, and it has the same width of 288px, so we can use the same logic to reset the position.

Add a new system called move_ground in the systems.rs file:

// systems.rs
 
pub fn move_ground(time: Res<Time>, mut query: Query<&mut Transform, With<Ground>>) {
    let mut ground_transform = query.single_mut();
    let delta = time.delta().as_secs_f32();
    let delta_x = 150. * delta; // move faster because it's closer to the camera perspective
 
    ground_transform.translation.x -= delta_x;
 
    if ground_transform.translation.x < -288.0 {
        ground_transform.translation.x = 0.;
    }
}

Update the sprite width:

// Spawn the Ground
commands.spawn((
    SpriteBundle {
        texture: asset_server.load("texture/base.png"),
        sprite: Sprite {
            custom_size: Some(Vec2::new(WINDOW_WIDTH + 288. * 2., 112.)),
            ..default()
        },
        transform: Transform::from_xyz(0., -250., 1.),
        ..default()
    },
    ImageScaleMode::Tiled {
        tile_x: true,
        tile_y: false,
        stretch_value: 1.,
    },
    Ground,
));

Add it to main.rs:

// main.rs
 
fn main() {
    App::new()
        .add_systems(Startup, setup)
        .add_systems(Update, blink_space_bar_text)
        .add_systems(Update, move_background)
        .add_systems(Update, move_ground)
        .add_plugins(MyPlugin)
        .run();
}

Running the code, you'll see that the ground is moving to the left a little bit faster than the background.

Moving Ground Moving Ground

Animating the Bird

If you remember in the previous part, we talked about Spritesheets which is a collection of images in a single image file. The spritesheet for the bird contained three textures, one for each frame of the bird's animation.

Now, we need to use these textures to animate the bird, we change it to a different texture at a certain interval, which means we're going to use a Timer.

We worked with timers before, so this is going to be quite easy.

Let's update the bird component and add a Timer to it:

// components.rs
 
#[derive(Component)]
pub struct Bird {
    pub timer: Timer,
}

The reason we're using named fields is because we will add more fields to the Bird component in the next steps, so we use named fields instead of tuple fields to make it easier to read and understand.

Updating the spawn code in the setup.rs file:

// setup.rs
 
// Spawn the bird
commands.spawn((
    SpriteBundle {
        texture: asset_server.load("texture/bird.png"),
        transform: Transform::from_xyz(0., 0., 2.),
        ..default()
    },
    TextureAtlas {
        index: 1,
        layout: texture_atlas_layouts.add(TextureAtlasLayout::from_grid(
            UVec2::new(34, 24),
            3,
            1,
            None,
            None,
        )),
    },
    Bird {
        timer: Timer::from_seconds(0.2, TimerMode::Repeating),
    },
));

We added the timer with a duration of 0.2 seconds.

Create the system to animate the bird:

// systems.rs
 
pub fn animate_bird(time: Res<Time>, mut query: Query<(&mut Bird, &mut TextureAtlas)>) {
    for (mut bird, mut texture_atlas) in query.iter_mut() {
        let delta = time.delta();
 
        bird.timer.tick(delta);
 
        if bird.timer.finished() {
            texture_atlas.index = if texture_atlas.index == 2 {
                0
            } else {
                texture_atlas.index + 1
            };
        }
    }
}

What we need to query is the TextureAtlas because we want to change the index of the texture atlas when the timer finishes, we also need to query the bird so that we can tick the timer and check if the timer has finished.

We also used the Time to get the delta time and tick the timer.

In this system we have used iter_mut() instead of single_mut(), if you want to use single_mut(), you can go ahead and do it, it will be exactly the same as this, the only difference is that if there are more than one entity returned in the query, single_mut() is going to panic.

Run the code and you'll see a cool animation of the bird flapping its wings.

Bird Animation Bird Animation


That's it for this part of the tutorial, we learned how to add systems to our game, how to use timers, query components, and adding animations to the game.

In the next part of the tutorial, we're going to learn about Resources and how to use them. After we add resources, we'll add more systems to make the game a little bit more interactive.