5 tips for better platformer controls
Ahh, the platformer. The first game that many game developers make, and with good reason. Many of us grew up with them, so there’s often a nostalgia factor at play, but they’re also fairly easy to create since most engines give you everything you need to make one right out of the box, and therefore make for a good jumping off point in the world of game development. But making a good, satisfying platformer is a lot harder and more involved than throwing a physics body on top of a tilemap and calling it a day, so here’s a few tips for how you can make your platformer feel better to play, from the basics to some more advanced tricks.
Also, to help provide a reference on how to implement the techniques mentioned today, I’ll be using my advanced state machine as the backbone of my player controller and showing the code I add to it along the way.
Don’t use rigidbodies
Let’s go ahead and get this one out of the way. Rigidbodies exist to simulate actual physics objects. You apply forces to move them, define their center of mass, add materials defining their coefficients for friction and collisions and so on so that you can have something that behaves according to the laws of physics. But good platforming isn’t realistic, so why would you use a physics body designed to be (reasonably) realistic? You’re better off using a simplified physics solution (which also comes with a nice performance boost) and adding in the complexity you need so it behaves exactly how you want it to, rather than constantly fighting the physics simulation because the friction that makes horizontal movement feel good means you get stuck on walls when you jump…
Plays just like Mario…
Buffer jump inputs
A common issue with platforming occurs when the player is in the air, about to land on the ground, and immediately wants to jump again. So they go ahead and press jump, but are actually a frame or two early from passing any ground collision checks and end up just landing on the ground instead of jumping. With jump buffering, we hold onto their jump command for a few frames and automatically jump for them if they landd on the ground within the allotted time, helping bridge the gap between the player’s intended action and the millisecond-perfect precision required to actually pull off such a movement.
We can accomplish this by simply listening for jump inputs while in the fall state and starting a timer. If the player lands on the ground before the timer runs out, we transition to a jump state rather than any of our relevant ground states (walking, running, standing, etc).
#fall.gd
extends BaseState
export (float) var jump_buffer_time = 0.1
export (float) var move_speed = 60
export (NodePath) var run_node
export (NodePath) var idle_node
export (NodePath) var jump_node
onready var run_state: BaseState = get_node(run_node)
onready var idle_state: BaseState = get_node(idle_node)
onready var jump_state: BaseState = get_node(jump_node)
var jump_buffer_timer: float = 0.0
func enter() -> void:
.enter()
jump_buffer_timer = 0
func input(event: InputEvent) -> BaseState:
if Input.is_action_just_pressed('jump'):
jump_buffer_timer = jump_buffer_time
return null
func physics_process(delta: float) -> BaseState:
jump_buffer_timer -= delta
var move = 0
if Input.is_action_pressed("move_left"):
move = -1
player.animations.flip_h = true
elif Input.is_action_pressed("move_right"):
move = 1
player.animations.flip_h = false
player.velocity.x = move * move_speed
player.velocity.y += player.gravity
player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
if player.is_on_floor():
if jump_buffer_timer > 0:
return jump_state
if move != 0:
return run_state
else:
return idle_state
return null
Add coyote time
Named after a certain coyote with a propensity for temporarily defying gravity, coyote time is about giving the player a small margin of error for timing their jumps when running off of a ledge. Oftentimes, the player will wait until the last possible moment to jump so they can cover as much horizontal distance as possible, but oftentimes they end up being a couple of frames too late. The physics body has already left the ground and the character is now in a falling state, which either doesn’t allow for jumping or makes the player use up their precious double jump. Rather than punish them for this, we can let our falling state watch for a jump input right after it takes over and allow the player to switch to the jump state if they’re only a fraction of a second late.
We can do this by simply setting a timer when they enter the fall state and listening for a jump event. As long as the jump occurs before this timer has run out, we let the player trigger the jump state. Otherwise we make them fall. Tweak the length of time to your game’s needs. A shorter window of error is better for more precise or difficult games, while a more generous window can be great for more casual play.
#fall.gd
extends BaseState
export (float) var coyote_time = 0.2
export (float) var move_speed = 60
export (NodePath) var run_node
export (NodePath) var idle_node
export (NodePath) var jump_node
onready var run_state: BaseState = get_node(run_node)
onready var idle_state: BaseState = get_node(idle_node)
onready var jump_state: BaseState = get_node(jump_node)
var coyote_timer: float = 0.0
func enter() -> void:
.enter()
coyote_timer = coyote_time
func input(event: InputEvent) -> BaseState:
if Input.is_action_just_pressed('jump'):
if coyote_timer > 0:
return jump_state
return null
func physics_process(delta: float) -> BaseState:
coyote_timer -= delta
var move = 0
if Input.is_action_pressed("move_left"):
move = -1
player.animations.flip_h = true
elif Input.is_action_pressed("move_right"):
move = 1
player.animations.flip_h = false
player.velocity.x = move * move_speed
player.velocity.y += player.gravity
player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
if player.is_on_floor():
if move != 0:
return run_state
else:
return idle_state
return null
Push off ledges
This one is a bit trickier to get right since you have to deal with some pretty fine-grained tolerances, but it can help make your movement feel smoother and closer to player intent (noticing a pattern yet?). Similar to coyote time, the idea behind ledge snapping is to to help the player jump around ledges where they’d normally hit their head. If they jump with a horizontal difference within our desired threshold, we push them in the direction that lets them slide around the ledge and reach their full jump height. We can do this by using two short raycasts on each side of the player pointed upward. With these four raycasts, we can determine what space, if any, is available above the player for them to be snapped to. If an outer raycast hits a ledge but the inner raycast and opposite raycasts don’t, we know we can slide away from the raycast that hit. This method can probably be implemented or simplified on a per-game basis, but I’ll leave this example implementation as-is.
#jump.gd
extends BaseState
export (float) var jump_force = 100
export (float) var move_speed = 60
export (NodePath) var fall_node
export (NodePath) var run_node
export (NodePath) var idle_node
onready var fall_state: BaseState = get_node(fall_node)
onready var run_state: BaseState = get_node(run_node)
onready var idle_state: BaseState = get_node(idle_node)
func enter() -> void:
# This calls the base class enter function, which is necessary here
# to make sure the animation switches
.enter()
player.velocity.y = -jump_force
func physics_process(delta: float) -> BaseState:
var move = 0
if Input.is_action_pressed("move_left"):
move = -1
player.animations.flip_h = true
elif Input.is_action_pressed("move_right"):
move = 1
player.animations.flip_h = false
# Arbitrary offset, adjust as needed, or even use the raycasts to determine exactly how much to move
if player.right_outer.is_colliding() and !player.right_inner.is_colliding() \
and !player.left_inner.is_colliding() and !player.left_outer.is_colliding():
player.global_position.x -= 5
elif player.left_outer.is_colliding() and !player.left_inner.is_colliding() \
and !player.right_inner.is_colliding() and !player.right_outer.is_colliding():
player.global_position.x = 5
player.velocity.x = move * move_speed
player.velocity.y += player.gravity
player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
if player.velocity.y > 0:
return fall_state
if player.is_on_floor():
if move != 0:
return run_state
return idle_state
return null
Maybe not as smooth as Celeste and its squishy animations, but it gets the job done.
Use a custom jump function
Now is when things really start to open up and your creativity can go crazy. There’s no reason you have to just apply an upward force for a jump and leave it at that. You can, and probably should if the platforming is significant part of your game, write a custom jump function that provides a more finely tuned jump mechanic. To get started with this, there’s an excellent GDC talk about the math behind custom jumps that is essentially required viewing for this section. It’s thirty minutes of jump mathematics, but it’s pretty approachable and has some nice graphs, so I recommend checking it out. But since I’m not here just to post YouTube videos, here’s a few ideas for how you can customize your jumps.
Fall faster than you jump
In most games, the falling part of the jump is less interesting than the jumping part, since we’ve already made it over the spike pit or squashed an enemy, and are ready to focus on the next challenge, so using a higher gravity when the player is falling is a great way to keep the pace of the platforming high. The player spends less time waiting to get back on the ground and it keeps the controls feeling snappy and less floaty.
#jump.gd
extends BaseState
export (float) var fall_gravity_multiplier = 2.0
export (float) var move_speed = 60
export (NodePath) var run_node
export (NodePath) var idle_node
export (NodePath) var jump_node
onready var run_state: BaseState = get_node(run_node)
onready var idle_state: BaseState = get_node(idle_node)
onready var jump_state: BaseState = get_node(jump_node)
func physics_process(delta: float) -> BaseState:
var move = 0
if Input.is_action_pressed("move_left"):
move = -1
player.animations.flip_h = true
elif Input.is_action_pressed("move_right"):
move = 1
player.animations.flip_h = false
player.velocity.x = move * move_speed
player.velocity.y += player.gravity * fall_gravity_multiplier
player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
if player.is_on_floor():
if move != 0:
return run_state
else:
return idle_state
return null
You can also go the way of Mario and have a variable height to your jump, where holding the jump button down gives you the full height of the jump while releasing the button early let’s you start falling earlier. You could simply zero out their vertical velocity and let them start falling right away, but an alternative to this is to simply slash the player’s vertical velocity by X% if they release the jump early or applying a heavier gravity to jumps that are released early, resulting in a bit of a smoother transition from jumping to falling than just immediately dropping.
In this code snippet, I halve the player’s vertical velocity when they release jump early, which I think makes for a pretty smooth variable jump.
#jump.gd
extends BaseState
export (float) var jump_force = 100
export (float) var move_speed = 60
export (NodePath) var fall_node
export (NodePath) var run_node
export (NodePath) var idle_node
onready var fall_state: BaseState = get_node(fall_node)
onready var run_state: BaseState = get_node(run_node)
onready var idle_state: BaseState = get_node(idle_node)
func enter() -> void:
# This calls the base class enter function, which is necessary here
# to make sure the animation switches
.enter()
player.velocity.y = -jump_force
func physics_process(delta: float) -> BaseState:
var move = 0
if Input.is_action_pressed("move_left"):
move = -1
player.animations.flip_h = true
elif Input.is_action_pressed("move_right"):
move = 1
player.animations.flip_h = false
if Input.is_action_just_released('jump'):
player.velocity.y *= 0.5
player.velocity.x = move * move_speed
player.velocity.y += player.gravity
player.velocity = player.move_and_slide(player.velocity, Vector2.UP)
if player.velocity.y > 0:
return fall_state
if player.is_on_floor():
if move != 0:
return run_state
return idle_state
return null
Determine a desired height and work backwards
Given a maximum height and time to reach that height, determining the required jump equation is trivial. For a tilemap-based game, defining your jump, or at least max jump, to always be a multiple of the tile size can help easily design nail-bitingly close jumps with expert precision. For more information on this technique, check out the GDC talk above or this great video by Gonkee.
Wrapup
And that’s it for today. Hopefully this gives you plenty of ideas about how you can make a better 2D character controller. Plus, Game Maker’s Toolkit just released an interactive video essay for you to play around with platformer control customization, so be sure to check that out as well!