Platformer Navigation: Part 1 - Movement
Introduction
This is based on a project I worked on a while ago which had AI characters that needed to be able to navigate through a (really) simple platformer world. That project was written in unity, but I will be reimplementing it in godot to try that out. Part one is about platformer movement. While a lot has already been said on that already, we can’t discuss generating navigation without knowing the mechanics of getting from A to B. The next post should hopefully be on actually generating navigation data and pathfinding through it, though if I run into any issues with godot in the process I might write about that first.
I used free (CC0) sprite sheets for the graphics, it should be this one, but I think I found and downloaded it somewhere else.
Links to the final demo are at the end of the post, but if you would like to have context the project files are here
Character setup
The player setup is pretty simple. Initially I was going to make a dynamic character, but this series was supposed to be on AI navigation and not getting Godot’s physics to work properly, so I went with kinematic.
The root CharacterBody2D has a movement script on it. This receives input from a controller script. The reason for this is that when we implement AI we can reuse the movement script (and possibly the animator), but swap the controller for something with AI instead of player input. The animator looks at the current state of the CharacterBody and decides what animation should be playing.
Controller
For the player, the controller simply passes the input to the movement script on its parent.
extends Node
func _process(delta):
var direction = Input.get_axis("MoveLeft", "MoveRight")
get_parent().move(direction)
get_parent().jump(Input.is_action_pressed("Jump"))
Animator
If we are moving left or right we update which direction we are facing. Since I didn’t want to flip the sprites myself this is used to set a negative x scale on the sprite to flip it. Note that the WalkRight animation is called that because it’s the direction the animation faces, when the negative x scale is set it faces left.
To check if we should use the jump sprite I use parent.is_on_floor()
rather than the y velocity, because the velocity is also 0 at the apex of the jump. While it’s unlikely to hit this exactly due to the frame timing having to line up perfectly, theoretically we could quickly flick from the jump animation and back in a frame at the top of the jump.
If we had a separate falling animation, we would need to check the velocity to see if we are going up or down. The direction we are facing is also separate from the animation selection because I want it to update regardless if we are jumping or what animation is playing.
extends Node
@onready var walk: SpriteFrames
@onready var idle: SpriteFrames
@onready var parent = get_parent()
@onready var sprite = $"../Sprite"
var facing = 1
func _process(delta):
if ! is_zero_approx(parent.velocity.x):
if parent.velocity.x > 0:
facing = 1
else:
facing = -1
sprite.scale.x = facing
if ! parent.is_on_floor():
sprite.play("Jump")
elif is_zero_approx(parent.velocity.x):
sprite.play("Idle")
else:
sprite.play("WalkRight")
Movement
First thing we need to do is accept input from the controller
extends CharacterBody2D
# Input variables set by controller
var direction = 0
func jump(pressed):
pass
func move(dir):
direction = dir
We’ll handle jumping later, but the function needs to be there so the scripts don’t error.
Next we can take the movement direction and use it to update the velocity in the physics tick.
func _physics_process(delta):
velocity.x = 2500 * direction * delta
move_and_slide()
This doesn’t feel very good because nothing in meatspace instantly starts moving. Instead, we should accelerate upto a max speed and decelerate back to 0.
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
move_and_slide()
The next easiest thing is probably falling. Just apply gravity when not on the floor. I haven’t added air resistance so this will keep speeding up until we hit the ground instead of reaching a terminal velocity.
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
if not is_on_floor():
velocity.y += gravity * delta
move_and_slide()
Next, jumping. We want to apply an instantaneous upwards velocity when the player presses the jump button, but there’s a slight issue here. The physics tick is separate from the game tick. For the left and right direction I just took the latest direction value since we update that every frame, but the jump input may only be true for around one frame. The jump input needs to hang around until we actually get around to processing it in the physics tick, which might not happen before it’s released. This means we should save it and unset after we process it instead of unsetting when the button is released.
extends CharacterBody2D
@export var acceleration = 2500.0
@export var maxSpeed = 600.0
@export var decelleration = 2250.0
var gravity = 981
var jumpVelocity = 500
# Input variables set by controller
var direction = 0
var startJump = false
func jump(pressed):
startJump = startJump or pressed
func move(dir):
direction = dir
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
if not is_on_floor():
velocity.y += gravity * delta
if startJump:
startJump = false
if is_on_floor():
velocity.y = -jumpVelocity
move_and_slide()
I don’t want to set a velocity though, because it’s not too clear how that relates to a level layout. Instead, it would be better to specify a jump height. Instead of gravity it would also be nicer to specify how long it takes to reach the apex of the jump.
You may remember the SUVAT equations from high school or something. If we consider the first part of the jump (to the apex) we have
s = vertical distance to peak
u = initial (jump) velocity (we need this)
v = final velocity (0 at the peak)
a = gravity (we need this)
t = time to get to the peak
We can start with
After substituting 0 for v
and rearranging.
This is slightly wrong though, because in 2d graphics we use a weird coordinate system where the vertical axis increases downward. This means we need to make it negative to point up.
i.e.
jumpVelocity = - 2 * jumpHeight / peakTime
For gravity, we can use
After substituting 0 for v
and rearranging again.
i.e.
gravity = 2 * jumpHeight / (peakTime * peakTime)
Note that I flipped the sign again.
Here is a graph of the jump trajectory. h
is the jump height, t
is the time it takes to reach the peak of the jump, s
is the player’s walk speed.
The red line shows the vertical position over time, and the green line uses the horizontal speed to change the x axis from time to the horizontal position assuming movement to the right while jumping.
We can export the jumpHeight
and peakTime
variables to configure the movement and then calculate jumpVelocity
and gravity
once when the character spawns.
extends CharacterBody2D
@export var acceleration = 2500.0
@export var maxSpeed = 600.0
@export var decelleration = 2250.0
@export var jumpHeight = 300
@export var peakTime = 0.3
var gravity = 0
var jumpVelocity = 0
# Input variables set by controller
var direction = 0
var startJump = false
func jump(pressed):
startJump = startJump or pressed
func move(dir):
direction = dir
func updateVars():
jumpVelocity = - 2 * jumpHeight / peakTime
gravity = 2 * jumpHeight / (peakTime * peakTime)
func _ready():
updateVars()
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
if not is_on_floor():
velocity.y += gravity * delta
if startJump:
startJump = false
if is_on_floor():
velocity.y = jumpVelocity
move_and_slide()
Variable height jumps
To allow the player to jump at varying heights we can’t change the jump velocity after releasing the jump button, because that’s already been applied. What we can do is increase the gravity when the player releases the jump button in order to fall faster. Platformers have done this since at least super mario.
This means we now specify
maximum jump height
time to reach the maximum height
minimum jump height
To calculate the gravity when the player releases the jump button we can’t just use the same equation like:
tapGravity = 2 * minJumpHeight / (peakTime * peakTime)
The reason is that we also used the max jump height and time to calculate the jump velocity.
jumpVelocity = - 2 * maxJumpHeight / peakTime
If we use a lower height and the same time to the lower peak then we would calculate a different initial velocity for the jump that should have been applied. Instead, the jump time has to decrease. We can use another equation to compute our gravity from the variables we already have.
After the same substitution (v = 0
) and rearranging.
i.e.
tapGravity = (jumpVelocity * jumpVelocity) / (2 * minJumpHeight)
Here’s a graph of the min and max jump heights. The red line shows the maximum possible jump, and the black line shows the minimum.
The other change we need to make here is to store whether the player is holding the jump button or not. Note that we unset startJump
because we are using that to make sure we don’t miss the initial trigger if there is no physics tick before the player releases it - we need another variable for this.
I also don’t like the character jumping every time it hits the floor with the jump button held. Instead, we now also require that the jump button wasn’t pressed in the call to jump
in the last update.
extends CharacterBody2D
@export var acceleration = 2500.0
@export var maxSpeed = 600.0
@export var decelleration = 2250.0
@export var maxJumpHeight = 300.0
@export var minJumpHeight = 90.0
@export var peakTime = 0.5
var holdGravity = 0
var tapGravity = 0
var jumpVelocity = 0
# Input variables set by controller
var direction = 0
var startJump = false
var jumpHeld = false
func jump(pressed):
startJump = startJump or (!jumpHeld and pressed)
jumpHeld = pressed
func move(dir):
direction = dir
func updateVars():
jumpVelocity = - 2 * maxJumpHeight / peakTime
holdGravity = 2 * maxJumpHeight / (peakTime * peakTime)
tapGravity = (jumpVelocity * jumpVelocity) / (2 * minJumpHeight)
func _ready():
updateVars()
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
if startJump:
startJump = false
if is_on_floor():
velocity.y = jumpVelocity
if not is_on_floor():
var gravity = holdGravity if jumpHeld and velocity.y < 0 else tapGravity
velocity.y += gravity * delta
move_and_slide()
Coyote time
The last thing I want to do for the player controller is to add coyote time, allowing the player to still jump after walking off the platform for a couple of frames.
There are a few reasons why the player could miss the edge of the platform, firstly the hitbox doesn’t perfectly match the player sprite. But a bigger reason is that the player sprite might not actually line up with the edge of the platform. Remember that nothing actually runs continuously, it’s actually skipping forward a few pixels every frame. This means that one frame we can be behind the platform edge and in the next frame we skip over it, the player can’t even theoretically time the jump perfectly at the edge of the platform because it might never line up. How much of an issue this is depends on the framerate (including what the monitor can actually display) as well as the resolution and player speed. There are other reasons why you may implement this - e.g. in first person games depth perception can be hard since you see the wold through a single perspective camera ( instead of two eyes which vary their focus ), and you also probably don’t want to be looking at the floor.
Instead of checking if we are currently on the floor before jumping, we want to check if we have been on the floor within a small window of time. The window resets while on the ground and decreases in the air. We also set it to zero afterward, otherwise if the player spams jump it could trigger multiple times (I’ve seen this behaviour before in AAA games, and this is probably why).
func _physics_process(delta):
if direction != 0:
velocity.x += acceleration * direction * delta
else:
velocity.x = move_toward(velocity.x, 0, decelleration*delta)
velocity.x = clampf(velocity.x, -maxSpeed, maxSpeed)
if is_on_floor():
jumpWindowRemaining = coyoteTime
else:
jumpWindowRemaining -= delta
if startJump:
startJump = false
if jumpWindowRemaining > 0:
velocity.y = jumpVelocity
jumpWindowRemaining = 0
if not is_on_floor():
var gravity = holdGravity if jumpHeld and velocity.y < 0 else tapGravity
velocity.y += gravity * delta