Lil Bit Of Game Dev

And other things

View on GitHub
19 March 2025

Stepping Up & Down Stairs in Godot 4

by ashley

For this guide, I’ve configured:

And, the following script attached:

class_name Player extends CharacterBody3D

# ...

func _physics_process(delta: float) -> void:
    # get player's raw movement input as a 2d vector
    var input_dir := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
    
    # apply camera's yaw rotation onto the input vector
    var wish_dir := camera_yaw.global_basis * Vector3(input_dir.x, 0, input_dir.y)
    
    # we have different movement properties depending on whether or not we're on floor.
    if is_on_floor():
        update_velocity_grounded(wish_dir, delta)
    else:
        update_velocity_air(wish_dir, delta)
    
    # we always apply gravity when we're not falling faster than terminal velocity
    apply_gravity(delta)
    
    # combine the horizontal and vertical components together & move
    velocity = Vector3(horizontal_velocity.x, vertical_speed, horizontal_velocity.z)
    move_and_slide()
    
    # get our new horizontal & vertical components after moving
    horizontal_velocity = Vector3(velocity.x, 0, velocity.z)
    vertical_speed = velocity.y

# ...

It works great! Its not too floaty, and not too snappy. It handles things like speed acceleration and exponential decay like friction, max speed clamping, terminal falling speed, gravity, etc. But…

I can’t climb stairs!

clip brush slope

Climbing stairs is a surprisingly unintuitive problem. Theres one obvious solution we could try, which is to add an invisible collision mesh with an angle lower than our controller’s max slope angle:

clip brush slope

This works pretty well, actually! My character controller is able to move up and down this without any changes:

clip brush slope

I’m not satisfied, though; After thinking about it, there are some problems:

I’ve played plenty of games that don’t use stair clipping at all but have really smooth stair stepping - like Selaco, Ion Fury, and Quake 3. I want the boon of a robust character controller that can walk up and down any appropriate geometry with satisfying smoothing.

Enter: test_move

Godot’s PhysicsBody3D has a function, test_move, which tests if a particular motion applied to a PhysicsBody3D will hit anything. The test gives us back some useful information in the result, like how far the test travelled before hitting anything, and the normal of the surface the test hit.

So, we could do a test motion downward, with its origin at global_position + (velocity * delta) + Vector3(0, max_step_height, 0), with a downard motion thats max_step_height in length:

                           test_move     
                           ┌──────┐           
                           │      │           
                           │      │           
 player                    │      │           
┌──────┐                   │      │           
│      │  player velocity  │      │           
│      ├─────────────────► │      │           
│      │                   └──┬───┘           
│      │                      │               
│      │                      │max step height
│      │                      │               
└──────┘                      ▼               

So we can write a function that does that test for us:

func stair_step_up(delta: float) -> void:
    var sweep_transform := global_transform.translated(horizontal_velocity * delta + Vector3(0, max_step_height, 0))
    var result := KinematicCollision3D.new()
    var hit_something := test_move(sweep_transform, Vector3(0, -max_step_height, 0), result, safe_margin)
    if hit_something:
        sweep_transform = sweep_transform.translated(result.get_travel())
        global_position.y = sweep_transform.origin.y

And call it before we call move_and_slide():

func _physics_process(delta: float) -> void:
    # ...
    stair_step_up(delta)

    # combine the horizontal and vertical components together & move
    velocity = Vector3(horizontal_velocity.x, vertical_speed, horizontal_velocity.z)
    move_and_slide()
    # ...

And give it a try, without any extra stair clipping:

clip brush slope

It works!!! Though, its not particularly smooth. I’ll get to smoothing out the vertical motion later in this post.

It’s not too hard to come up with some obvious problems to this approach though, it:

Less Naive Test

We can account for ceilings & walls with a relatively trivial change to our naive stair step procedure, by testing up, and forward, before testing down. If we hit anything during the upwards or forwards test, we adjust our next test’s origin to account for the collision.

┌──────┐                 ┌──────┐
│      │                 │      │
│      │ player velocity │      │
│sweep ├────────────────►│sweep │
│right │                 │down  │
│      │                 │      │
│      │                 │      │
└──────┘                 └───┬──┘
    ▲                        │   
    │max step height         │   
┌───┴──┐                     │   
│      │                     │   
│      │                     │   
│sweep │       step remainder│   
│up    │                     │   
│      │                     │   
│      │                     │   
└──────┘                     ▼   
 player                 destination

And the updated procedure:

func stair_step_up(delta: float) -> void:
    var sweep_transform := global_transform
    var result := KinematicCollision3D.new()
    
    # sweep up
    test_move(sweep_transform, Vector3(0, max_step_height, 0), result, safe_margin)
    
    # sweep forward using player's velocity
    var height_travelled = result.get_travel().y
    sweep_transform = sweep_transform.translated(result.get_travel())
    test_move(sweep_transform, horizontal_velocity * delta, result, safe_margin)
    
    # sweep back down, at most the amount we travelled from the sweep up
    sweep_transform = sweep_transform.translated(result.get_travel())
    if !test_move(sweep_transform, Vector3(0, -height_travelled, 0), result, safe_margin):
        # don't bother if we don't hit anything
        return

    sweep_transform = sweep_transform.translated(result.get_travel())
    global_position.y = sweep_transform.origin.y

And that works… but there is certainly some unexpected behaviour that happens occasionally when going up each step:

clip brush slope

Mis-steps

I’m making some assumptions about why this happens:

I believe this happens because stair_step_up only moves the player vertically. It could be possible that the vertical velocity due to gravity causes the player to fall too fast for Jolt to determine that the player is “on” the next step.

So, I’ll try to solve the problem given these assumptions. Lets force the body to only move the horizontal component first, then handle vertical after:

func _physics_process(delta: float) -> void:
    # ...
    stair_step_up(delta)
    
    # horizontal-only move
    velocity = horizontal_velocity
    move_and_slide()

    horizontal_velocity = Vector3(velocity.x, 0, velocity.z)

    # the horizontal motion may have induced some vertical motion after a slide iteration
    vertical_speed += velocity.y

    # vertical-only move
    velocity = Vector3(0, vertical_speed, 0)
    move_and_slide()
    
    # the vertical motion may have induced some horizontal motion after a slide iteration
    vertical_speed = velocity.y
    horizontal_velocity.x += velocity.x
    horizontal_velocity.z += velocity.z

Lets see how that feels:

fixed missteps

Not bad. Its not a perfect solution since I effectively doubled the number of slide iterations I’m doing each frame - but spending time solving for a more performant solution would be time spent prematurely on an optimization I may not even need.

Good enough is better than good.

Small Gains

We can make some small improvements to the stair step procedure. For starters, we shouldn’t be trying to step up if the player isn’t grounded, or if their velocity is zero:

func stair_step_up(delta: float) -> void:
    if !is_on_floor() or horizontal_velocity == Vector3.ZERO:
        return

    # ...

We shouldn’t be running the entire sweep if theres not ledge we could potentially step up onto in the first place:

    # ...

    # don't run through if theres nothing for us to step up onto
    if !test_move(sweep_transform, horizontal_velocity * delta, result, safe_margin):
        return

    # capture whatever remainder was left over from the horizontal move
    # to be used in the forward sweep
    var horizontal_remainder := result.get_remainder()

    # sweep up, from the wall we hit
    sweep_transform = sweep_transform.translated(result.get_travel())
    test_move(sweep_transform, Vector3(0, max_step_height, 0), result, safe_margin)
    
    var height_travelled := result.get_travel().y

    # sweep forward using player's velocity
    sweep_transform = sweep_transform.translated(result.get_travel())
    test_move(sweep_transform, horizontal_remainder, result, safe_margin)

    # ...

We shouldn’t try stepping down if the height traveled by the up-sweep is too small:

    # ...
    # sweep up, from the wall we hit
    sweep_transform = sweep_transform.translated(result.get_travel())
    test_move(sweep_transform, Vector3(0, max_step_height, 0), result, safe_margin)

    var height_travelled := result.get_travel().y
    if height_travelled <= 0:
        return
    
    # ...

We shouldn’t try stepping onto a surface thats too steep for us to walk onto anyways:

    # after sweeping down, skip if the hit surface is too steep
    var floor_angle = result.get_normal().angle_to(Vector3.UP)
    if absf(floor_angle) > floor_max_angle:
        return

Stepping down

Stepping down is pretty similar to what we’ve done so far, except we only need to test straight beneath the player:

 player                          
┌──────┐                         
│      │                         
│      │                         
│sweep │                         
│down  │                         
│      │                         
│      │                         
└───┬──┘                         
    │                            
    │max step height             
    │                            
    ▼                            

So, lets just do that:

func stair_step_down() -> void:
    var result := KinematicCollision3D.new()
    if !test_move(global_transform, Vector3(0, -max_step_height, 0), result, safe_margin):
        return

    var new_transform := global_transform.translated(result.get_travel())
    global_transform = new_transform

And call it in _physics_process after move_and_slide():

func _physics_process(delta: float) -> void:
    # ...
    stair_step_up(delta)

    # horizontal-only move
    velocity = horizontal_velocity
    move_and_slide()
    # ...

    stair_step_down()
    # ...

We call stair_step_down() after move_and_slide(); if we don’t, the player could be in the air until next physics frame, even though they should step down.

Small Gains

Just like the step-up procedure, there are some scenarios where we don’t need to test downward at all:

func _physics_process(delta: float) -> void:
    # ...
    var was_grounded := is_on_floor()
    stair_step_up(delta)

    # horizontal-only move
    velocity = horizontal_velocity
    move_and_slide()
    # ...

    stair_step_down(was_grounded)
    # ...

func stair_step_down(was_grounded: bool) -> void:
    if !was_grounded or velocity.y >= 0 or is_on_floor():
        return
    # ...

We can also avoid a situation where we’re not snapped to the ground by calling apply_floor_snap() after moving the player:

func stair_step_down(was_grounded: bool) -> void:
    # ...
    global_transform = new_transform
    apply_floor_snap()

Camera Smoothing

To make stepping up & down less stuttery, we can smooth the position of the camera whenever the player steps up or down. Lets add a couple signals indicating whenever we step up or down:

signal stepped(vertical_travel: float)

We pass in the absolute vertical distance travelled whenever we step up or down:

func stair_step_up(delta: float) -> void:
    # ...

    var distance := sweep_transform.origin.y - global_position.y
    global_position.y = sweep_transform.origin.y
    stepped.emit(distance)

func stair_step_down(was_grounded: bool) -> void:
    # ...

    var previous_y := global_position.y
    global_transform = new_transform
    apply_floor_snap()
    
    var distance := global_position.y - previous_y
    stepped.emit(distance)

We can connect this signal to a function in the script attached to the camera, which offsets the camera’s local position by the height travelled, and smooths the camera’s local position back to 0:

extends Camera3D

func _on_player_stepped(distance: float) -> void:
    position.y -= distance

func _process(delta: float) -> void:
    position.y = Math.exp_decay_f(position.y, 0, 20, delta)

And, Math.exp_decay_f is Freya Holmer’s frame-rate-independent smoothing function:

static func exp_decay_f(a: float, b: float, decay: float, delta: float) -> float:
    return b + (a - b) * exp(-decay * delta)

clip brush slope

Wow. That is really nice. Its not perfectly interpolating between each step, but I personally really like that it doesn’t do that. This feels like theres weight to each step, and the code itself is really simple and satisfying.

Iterating Sweeps

Theres one more thing we can do to improve the accuracy of our step-up procedure, and to decrease it’s jankiness in some rare situations: iterating & sliding.

Instead of just stopping whenever we detect a collision during one of our initial sweeps, we could “iterate” the sweep a few times by “sliding” the motion against the collision’s normal. I have a little function that does all this for me:

func iterate_sweep(
    sweep_transform: Transform3D,
    motion: Vector3,
    result: KinematicCollision3D
) -> Transform3D:
    for i in max_step_up_slide_iterations:
        var hit := test_move(sweep_transform, motion, result, safe_margin)
        sweep_transform = sweep_transform.translated(result.get_travel())
        if not hit:
            break

        var ceiling_normal := result.get_normal()
        motion = motion.slide(ceiling_normal)
    
    return sweep_transform

Then we replace our up- and forward- sweep tests with calls to iterate_sweep:

func stair_step_up(delta: float) -> void:
    # ...
    # don't run through if theres nothing for us to step up onto
    if !test_move(sweep_transform, horizontal_velocity * delta, result, safe_margin)
        return

    var horizontal_remainder := result.get_remainder()

    # sweep up
    sweep_transform = sweep_transform.translated(result.get_travel())
    var pre_sweep_y := sweep_transform.origin.y
    sweep_transform = iterate_sweep(sweep_transform, Vector3(0, max_step_height, 0), result)

    var height_travelled := sweep_transform.origin.y - pre_sweep_y
    if height_travelled <= 0:
        return

    # sweep forward using player's velocity
    sweep_transform = iterate_sweep(sweep_transform, horizontal_remainder, result)

    # sweep back down, at most the amount we travelled from the sweep up
    # ...

With that we get some better accuracy around walls & low slanted ceilings:

clip brush slope

Slopes

We actually don’t need to make any special considerations for this code to work on slopes! This implementation is sufficiently generalized that it should work on most reasonable geometry!

slope walking

Smooth (:

Thats It!

For me, this is a pretty satisfying character controller. Its perfect for the kinds of games I work on, and could be easily augmented to have more complex movement features without disrupting the stair sweep code.

I’ve got an example project with the fully implemented player controller here, if you wanted to see the full code.

Thanks for reading <3

tags: godot - gamedev