CharacterBody2D: align with surface

Problem

You need your character body to align with the surface it’s standing on.

Solution

We’ll start with a basic kinematic platform character. See the Platform character recipe for details.

We have the following code for movement:

func _physics_process(delta):
    velocity.y += gravity * delta
    var dir = Input.get_axis("walk_left", "walk_right")
    velocity.x = dir * speed

    move_and_slide()
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = jump_speed

As you can see, there are a couple of problems. First, the character flies off the slope when running. It’s also sliding down the slope when there’s no input.

We can partially solve this by switching from move_and_slide() to move_and_slide_with_snap():

snap = Vector2.DOWN * 128 if !is_jumping else Vector2.ZERO
velocity = move_and_slide_with_snap(velocity, snap, Vector2.UP, true)

Now we have an upward “hop” when we stop on the way up the slope. This is because our x velocity is set to 0 by the lack of input, but the y is not.

Orienting the velocity

We can fix this by orienting our velocity relative to the slope. To illustrate, let’s first rotate the character to align with the slope. We can do this by checking the floor normal when we’re on the floor:

if is_on_floor():
    rotation = get_floor_normal().angle() + PI/2

This hasn’t changed anything about the movement yet, but it does help us visualize what we need to do. When we’re on the slope, our local transform looks like this:

alt alt

Now when we move, we want our x velocity to align with our local x axis (the red arrow), and gravity/jump to align with local y (the green arrow). We can keep our input code the same, and just assume that velocity is always calculated in the local coordinate system. The only problem will be that move_and_slide() expects the velocity vector to be in global coordinates. Let’s adjust move_and_slide_with_snap() to account for this:

snap = transform.y * 128 if !is_jumping else Vector2.ZERO
velocity = move_and_slide_with_snap(velocity.rotated(rotation),
        snap, -transform.y, true)
# Convert velocity back to local space.
velocity = velocity.rotated(-rotation)

We’ve changed a few things here, so let’s look at them carefully.

  • The snap vector is now our local down vector, so it will always point directly into the slope.
  • The floor_normal parameter is also changed to the local up direction (-transform.y).
  • We convert velocity to global by rotating it to match the player’s rotation, then revert the resulting velocity back to local by doing the reverse.

The result:

Wrapping up

This technique allows for a wide range of possible platformer-style movement schemes. For example, you can do fun things like this:

Here’s the full script:

func _physics_process(delta):
    get_input()
    velocity.y += gravity * delta
    snap = transform.y * 128 if !is_jumping else Vector2.ZERO
    velocity = move_and_slide_with_snap(velocity.rotated(rotation),
                    snap, -transform.y, true, 4, PI/3)
    velocity = velocity.rotated(-rotation)

    if is_on_floor():
        rotation = get_floor_normal().angle() + PI/2
        is_jumping = false
        if Input.is_action_just_pressed("ui_up"):
            is_jumping = true
            velocity.y = jump_speed