Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Add Acceleration and Velocity

Games tend to feel much more natural and have some ephemeral "quality" to them if they use acceleration and velocity for movement instead of directly moving at maximum speed all the time. For some precision genres like super precise platformers and bullet hell games, it's actually better to just use direct movement. Even those games will benefit from this infrastructure being in place and, I'd argue, can still feel better with just a little bit of momentum added.

The Velocity Component

We're going to want to move a lot of different things in our game. It's time to decouple movement from the player. For anything we want to move, we'll add a Velocity component and an apply_velocity system that uses it. This way we can have a system that just takes anything that has some kind of speed and moves it. We're deriving Default here so that we can assign it more easily in the future without having to specify our current_velocity is 0 every time we want to add this to an entity.

srclib.rs
#[derive(Default)]
#[component]
struct Velocity {
    current_velocity: Vec2,
    max_velocity: f32,
}


#[system]
fn apply_velocity(
    mut entities_to_move: Query<(Mut<Transform>, Ref<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    entities_to_move.for_each(|(mut transform, velocity)| {
        transform.position += velocity.current_velocity * frame_constants.delta_time;
    });
}

The Acceleration Component

A bullet fired from a gun likely doesn't need acceleration (for the type of game we're making). Our player, and likely our enemies, do. That's why we create these as separate components and systems. Note that to work as intended, our acceleration system must come before our velocity one. We want a separate acceleration and deceleration amount for maximum control. We'll also be calculating a target_velocity to know whether we're trying to stop or not, which will change the behavior slightly. Lastly we need a ZERO_VELOCITY_THRESHOLD for when we can safely round a velocity down to zero, making sure that things actually stop moving when they're supposed to.

srclib.rs

const ZERO_VELOCITY_THRESHOLD: f32 = 0.1; // You can change this!

#[derive(Default)]
#[component]
struct Acceleration {
    acceleration_rate: f32,
    deceleration_rate: f32,
    target_velocity: Vec2,
}

#[system]
fn apply_aceleration(
    mut velocities_to_adjust: Query<(Ref<Acceleration>, Mut<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    velocities_to_adjust.for_each(|(acceleration, mut velocity)| {
        // Calculate the difference between current and target velocity
        let velocity_delta = acceleration.target_velocity - velocity.current_velocity;

        // Determine if we're decelerating, if we're trying to get to no velocity
        // or if our target velocity is opposite our current.
        let is_decelerating = acceleration.target_velocity == Vec2::ZERO
            || acceleration.target_velocity.dot(velocity.current_velocity) < 0.0;

        let rate = if is_decelerating {
            acceleration.deceleration_rate
        } else {
            acceleration.acceleration_rate
        };

        // Calculate the maximum change allowed this frame
        let max_change = rate * frame_constants.delta_time;

        // Apply the change, clamped to the maximum rate
        let velocity_change = velocity_delta.clamp_length_max(max_change);
        velocity.current_velocity += velocity_change;

        // Check if we're trying to stop on each axis.
        if acceleration.target_velocity.x.abs() < DONE_MOVING_THRESHOLD
            && velocity.current_velocity.x.abs() < DONE_MOVING_THRESHOLD
        {
            velocity.current_velocity.x = 0.;
        }
        if acceleration.target_velocity.y.abs() < DONE_MOVING_THRESHOLD
            && velocity.current_velocity.y.abs() < DONE_MOVING_THRESHOLD
        {
            velocity.current_velocity.y = 0.;
        }

        // Clamp to max velocity
        velocity.current_velocity = velocity
            .current_velocity
            .clamp_length_max(velocity.max_velocity);

    });
}

Applying to Our Player

We'll need some more config values for our player to have things work as intended. For consistency we'll change PLAYER_MOVE_SPEED to PLAYER_MAX_VELOCITY. In addition, we'll have to spawn our player with Velocity and Acceleration components, and add a new system to apply them, replacing our old move_player system that is now no longer necessary.

srclib.rs
const PLAYER_MAX_VELOCITY: f32 = 750.;
const PLAYER_ACCELERATION: f32 = 9000.;
const PLAYER_DECELERATION: f32 = 12000.;

#[system_once]
fn spawn_player() {
    let transform = &Transform {
        position: Vec2::new(110., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.96, 0.65, 0.14, 0.9);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };

    let velocity = &Velocity {
        max_velocity: PLAYER_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: PLAYER_ACCELERATION,
        deceleration_rate: PLAYER_DECELERATION,
        ..Default::default()
    };


    Engine::spawn(bundle!(
        transform,
        color,
        circle_render,
        &Player,
        velocity,
        acceleration
    ));
}


#[system]
fn update_player_acceleration(
    player_input: Ref<PlayerInput>,
    mut player_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, velocity, _)) = player_query.get_mut(0) else {
        return;
    };

    acceleration.target_velocity = player_input.move_axes * velocity.max_velocity;
}

The let Some(... unpacks the query into more easily referenceable parts.

If you've applied all the above changes, you should have a character that moves around with just a tiny bit more polished feeling than if we hadn't done this.

Dial in your Movement Values

You may dislike some of the consts and want to tweak them. With Bloop's hot module reloading, that should be super simple to test, but since we're assigning those consts at spawn they don't automatically sync when you add them. You may wish to add this system to make things automatically sync for you.

#[system]
fn sync_player_movement_to_config_consts(
    mut player: Query<(Mut<Acceleration>, Mut<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, mut velocity, _)) = player.get_mut(0) else {
        return;
    };
    velocity.max_velocity = PLAYER_MAX_VELOCITY;
    acceleration.acceleration_rate = PLAYER_ACCELERATION;
    acceleration.deceleration_rate = PLAYER_DECELERATION;
}

I recommend trying to dance around the blue enemy as best as you can, and if you feel like you can precisely run circles around it without touching it while the movement still has just the tiniest bit of ebb and flow - which, for me, the supplied values provide - then you can rest easily. Don't be afraid to experiment, though! What happens if you make the decel rate very low? What about accel? Play around with the values and don't be afraid to mess them up, you can always undo it. If you'd like, join me as I give the enemy some simple AI as our first optional side quest, or move right along to adding a camera.






My current lib.rs:

srclib.rs
use engine::prelude::*;
mod ffi;

const PLAYER_MAX_VELOCITY: f32 = 750.;
const PLAYER_ACCELERATION: f32 = 9000.;
const PLAYER_DECELERATION: f32 = 12000.;
const ZERO_VELOCITY_THRESHOLD: f32 = 0.1;

#[derive(Default)]
#[resource]
struct PlayerInput {
    move_axes: Vec2,
}

#[component]
struct Player;

#[derive(Default)]
#[component]
struct Velocity {
    current_velocity: Vec2,
    max_velocity: f32,
}

#[derive(Default)]
#[component]
struct Acceleration {
    acceleration_rate: f32,
    deceleration_rate: f32,
    target_velocity: Vec2,
}

#[system]
fn sync_player_movement_to_config_consts(
    mut player: Query<(Mut<Acceleration>, Mut<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, mut velocity, _)) = player.get_mut(0) else {
        return;
    };
    velocity.max_velocity = PLAYER_MAX_VELOCITY;
    acceleration.acceleration_rate = PLAYER_ACCELERATION;
    acceleration.deceleration_rate = PLAYER_DECELERATION;
}

#[system]
fn update_player_input(mut player_input: Mut<PlayerInput>, input: Ref<InputState>) {
    let key_to_f32 = |key_code| {
        if input.keys[key_code].pressed() {
            1.0
        } else {
            0.0
        }
    };

    player_input.move_axes = Vec2::new(
        key_to_f32(KeyCode::KeyD) - key_to_f32(KeyCode::KeyA),
        key_to_f32(KeyCode::KeyW) - key_to_f32(KeyCode::KeyS),
    )
    .clamp_length_max(1.);
}

#[system_once]
fn spawn_player() {
    let transform = &Transform {
        position: Vec2::new(110., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.96, 0.65, 0.14, 0.9);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };

    let velocity = &Velocity {
        max_velocity: PLAYER_MAX_VELOCITY,
        ..Default::default()
    };
    let acceleration = &Acceleration {
        acceleration_rate: PLAYER_ACCELERATION,
        deceleration_rate: PLAYER_DECELERATION,
        ..Default::default()
    };

    Engine::spawn(bundle!(
        transform,
        color,
        circle_render,
        &Player,
        velocity,
        acceleration
    ));
}

#[system_once]
fn spawn_enemy() {
    let transform = &Transform {
        position: Vec2::new(-110., 0.),
        scale: Vec2::splat(1.5),
        ..Default::default()
    };
    let color = &Color::new(0.2, 0.2, 0.9, 0.9);
    let circle_render = &CircleRender {
        num_sides: 20,
        ..Default::default()
    };
    Engine::spawn(bundle!(transform, color, circle_render));
}

#[system]
fn update_player_acceleration(
    player_input: Ref<PlayerInput>,
    mut player_query: Query<(Mut<Acceleration>, Ref<Velocity>, Ref<Player>)>,
) {
    let Some((mut acceleration, velocity, _)) = player_query.get_mut(0) else {
        return;
    };

    acceleration.target_velocity = player_input.move_axes * velocity.max_velocity;
}

#[system]
fn apply_acceleration(
    mut velocities_to_adjust: Query<(Ref<Acceleration>, Mut<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    velocities_to_adjust.for_each(|(acceleration, mut velocity)| {
        let velocity_delta = acceleration.target_velocity - velocity.current_velocity;

        let is_decelerating = acceleration.target_velocity == Vec2::ZERO
            || acceleration.target_velocity.dot(velocity.current_velocity) < 0.0;

        let rate = if is_decelerating {
            acceleration.deceleration_rate
        } else {
            acceleration.acceleration_rate
        };

        let max_change = rate * frame_constants.delta_time;

        let velocity_change = velocity_delta.clamp_length_max(max_change);
        velocity.current_velocity += velocity_change;

        if acceleration.target_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.x.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.x = 0.;
        }
        if acceleration.target_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
            && velocity.current_velocity.y.abs() < ZERO_VELOCITY_THRESHOLD
        {
            velocity.current_velocity.y = 0.;
        }

        velocity.current_velocity = velocity
            .current_velocity
            .clamp_length_max(velocity.max_velocity);
    });
}

#[system]
fn apply_velocity(
    mut entities_to_move: Query<(Mut<Transform>, Ref<Velocity>)>,
    frame_constants: Ref<FrameConstants>,
) {
    entities_to_move.for_each(|(mut transform, velocity)| {
        transform.position += velocity.current_velocity * frame_constants.delta_time;
    });
}

Yikes that's a long boy!