I’ve been making a FPS character controller for a personal project in GDScript and finished battling stairstep and clutter climbing. Here’s a simple solution I wanted to share you can copy-paste into your character controller. I provide a block of code with all the helper functions then you just add 3 lines of code to your _physics_process() around your move_and_slide().
I went through many solutions on Github and Youtube but I wasn’t happy with them but I finally came up with something that meets my requirements.
Common issues I found:
- Not consistently climbing steps or stepping off when walking nearly parallel to a stair
- Not consistently climbing steps at high or low velocity, or starting pressed against one
- Not stepping up or down steps when there’s a wall against them or sliding along a wall
- Calling test_move() or body_test_motion() many times per tick even if no collisions would occur
- Letting you climb up steep walls
- Letting you fly down steep surfaces
- Functions would fire many times per single step up several frames in row causing overhead
- Climbing over rigid bodies you would want to collide with
- Messing with jumping off of edges
- Not working with CylinderShape3D collision meshes, which are my favorite for their game feel. I could rant on this for a while but I won’t.
I dont know what edgecase bugs you’ll find with this, but it won’t be any of those I tried really hard!
REQUIRED SCENE SET UP TO USE THIS:
- CharacterBody3D with a box or cylinder collision shape. NO CAPSULES OR SPHERES!
- The local Y position of the CollisionShape must sit on the floor. Divide the height by 2 to get this position
add these three lines around your move_and_slide() call in physics_process()
if is_on_floor(): _last_frame_was_on_floor = Engine.get_physics_frames()
_saved_velocity = velocity
move_and_slide()
step_check(get_slide_collision_count())
and then paste this somewhere in your script. Adjust MAX_STEP_HEIGHT as needed
#region STAIR STEPPER
const MAX_STEP_HEIGHT := 1 # How high you can step up or down (minus collision detection margins)
func too_steep(normal: Vector3) -> bool:
return normal.angle_to(Vector3.UP) > floor_max_angle
var _step_up_last_frame = false
func step_check(collisions : int) -> void:
if not is_on_floor() and not _step_up_last_frame:
step_down_check()
elif collisions:
if step_up_check(collisions):
_step_up_last_frame = true
return
_step_up_last_frame = false
var _last_frame_was_on_floor : int
var _down_test_result := KinematicCollision3D.new()
func step_down_check():
var was_on_floor : bool = _last_frame_was_on_floor == Engine.get_physics_frames()
if velocity.y <= 0 and (was_on_floor):
if test_move(global_transform.translated(velocity.normalized() * 0.05), Vector3(0,-MAX_STEP_HEIGHT,0), _down_test_result):
global_position = Vector3(global_position.x, _down_test_result.get_position().y, global_position.z)
apply_floor_snap()
var _up_and_forward_test_result := KinematicCollision3D.new()
var _saved_velocity : Vector3
func step_up_check(collisions : int) -> bool:
if !is_zero_approx(velocity.y) or is_zero_approx(_saved_velocity.length_squared()):
return false
for idx in collisions:
var collision := get_slide_collision(idx)
var collider := collision.get_collider()
if collider is StaticBody3D or CSGMesh3D:
var normal := collision.get_normal()
if not too_steep(normal): continue
normal *= Vector3 (1, 0, 1)
var motion := -normal*0.05
var translated_transform := global_transform.translated(Vector3(0,MAX_STEP_HEIGHT,0))
if !test_move(translated_transform, motion, _up_and_forward_test_result):
translated_transform = translated_transform.translated(motion)
if test_move(translated_transform, -Vector3(0,MAX_STEP_HEIGHT,0), _down_test_result):
if not too_steep(_down_test_result.get_normal()) and (_down_test_result.get_position().y - global_position.y) > 0.01:
global_position = Vector3(global_position.x, _down_test_result.get_position().y, global_position.z) + (-normal * 0.013) # 0.013 of normal makes near-parralel step ups decisive
velocity = _saved_velocity
apply_floor_snap()
return true
else:
var second_normal : Vector3 = _up_and_forward_test_result.get_normal() * Vector3(1,0,1)
if is_equal_approx(normal.x, second_normal.x) and is_equal_approx(normal.z, second_normal.z):
continue
motion = motion.slide(second_normal).normalized() * 0.05
if !test_move(translated_transform, motion, _up_and_forward_test_result):
translated_transform = translated_transform.translated(motion)
if test_move(translated_transform, -Vector3(0,MAX_STEP_HEIGHT,0), _down_test_result):
if _down_test_result.get_position().y - global_position.y > 0.01:
if not too_steep(_down_test_result.get_normal()):
global_position = Vector3(global_position.x, _down_test_result.get_position().y + 0.001, global_position.z) + (second_normal.normalized() * 0.001) + (motion.normalized() * 0.001)
velocity = _saved_velocity
apply_floor_snap()
return true
return false
#endregion
There’s lots of early returns so the function will only try to step down stairs if you’re not on the floor, and will only do test moves if you are already collided with a wall with velocity.
To make it look smooth you can save your camera’s global position before step up check then interpolate it to the new position.
Here’s the full default CharacterBody3D with movement template with stair stepping function and basic left right mouse look. Just add a CharacterBody3D with a cylinder collision shape and set their Y position to 1, and add a Camera3D as a child to the CharacterBody3D with the default name.
extends CharacterBody3D
const SPEED = 5.0
const JUMP_VELOCITY = 4.5
#region STAIR STEPPER
const MAX_STEP_HEIGHT := 1 # How high you can step up or down (minus collision detection margins)
func too_steep(normal: Vector3) -> bool:
return normal.angle_to(Vector3.UP) > floor_max_angle
var _step_up_last_frame = false
func step_check(collisions : int) -> void:
if not is_on_floor() and not _step_up_last_frame:
step_down_check()
elif collisions:
if step_up_check(collisions):
_step_up_last_frame = true
return
_step_up_last_frame = false
var _last_frame_was_on_floor : int
var _down_test_result := KinematicCollision3D.new()
func step_down_check():
var was_on_floor : bool = _last_frame_was_on_floor == Engine.get_physics_frames()
if velocity.y <= 0 and (was_on_floor):
if test_move(global_transform.translated(velocity.normalized() * 0.05), Vector3(0,-MAX_STEP_HEIGHT,0), _down_test_result):
global_position = Vector3(global_position.x, _down_test_result.get_position().y, global_position.z)
apply_floor_snap()
var _up_and_forward_test_result := KinematicCollision3D.new()
var _saved_velocity : Vector3
func step_up_check(collisions : int) -> bool:
if !is_zero_approx(velocity.y) or is_zero_approx(_saved_velocity.length_squared()):
return false
for idx in collisions:
var collision := get_slide_collision(idx)
var collider := collision.get_collider()
if collider is StaticBody3D or CSGMesh3D:
var normal := collision.get_normal()
if not too_steep(normal): continue
normal *= Vector3 (1, 0, 1)
var motion := -normal*0.05
var translated_transform := global_transform.translated(Vector3(0,MAX_STEP_HEIGHT,0))
if !test_move(translated_transform, motion, _up_and_forward_test_result):
translated_transform = translated_transform.translated(motion)
if test_move(translated_transform, -Vector3(0,MAX_STEP_HEIGHT,0), _down_test_result):
if not too_steep(_down_test_result.get_normal()) and (_down_test_result.get_position().y - global_position.y) > 0.01:
global_position = Vector3(global_position.x, _down_test_result.get_position().y, global_position.z) + (-normal * 0.013) # 0.013 of normal makes near-parralel step ups decisive
velocity = _saved_velocity
apply_floor_snap()
return true
else:
var second_normal : Vector3 = _up_and_forward_test_result.get_normal() * Vector3(1,0,1)
if is_equal_approx(normal.x, second_normal.x) and is_equal_approx(normal.z, second_normal.z):
continue
motion = motion.slide(second_normal).normalized() * 0.05
if !test_move(translated_transform, motion, _up_and_forward_test_result):
translated_transform = translated_transform.translated(motion)
if test_move(translated_transform, -Vector3(0,MAX_STEP_HEIGHT,0), _down_test_result):
if _down_test_result.get_position().y - global_position.y > 0.01:
if not too_steep(_down_test_result.get_normal()):
global_position = Vector3(global_position.x, _down_test_result.get_position().y + 0.001, global_position.z) + (second_normal.normalized() * 0.001) + (motion.normalized() * 0.001)
velocity = _saved_velocity
apply_floor_snap()
return true
return false
#endregion
func _physics_process(delta: float) -> void:
# Add the gravity.
if not is_on_floor():
velocity += get_gravity() * delta
# Handle jump.
if Input.is_action_just_pressed("ui_accept") and is_on_floor():
velocity.y = JUMP_VELOCITY
# Get the input direction and handle the movement/deceleration.
# As good practice, you should replace UI actions with custom gameplay actions.
var input_dir := Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down")
var direction := (transform.basis * Vector3(input_dir.x, 0, input_dir.y)).normalized()
if direction:
velocity.x = direction.x * SPEED
velocity.z = direction.z * SPEED
else:
velocity.x = move_toward(velocity.x, 0, SPEED)
velocity.z = move_toward(velocity.z, 0, SPEED)
#SAVE FRAME THAT YOU WERE ON FLOOR AND VELOCITY
if is_on_floor(): _last_frame_was_on_floor = Engine.get_physics_frames()
_saved_velocity = velocity
#THEN CALL MOVE AND SLIDE
move_and_slide()
#THEN CHECK FOR STAIRS BASED ON COLLISIONS
step_check(get_slide_collision_count())
# Added mouselook to player camera
@onready var PlayerCamera := $Camera3D
var sensitivity := 0.001
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
elif event.is_action_pressed("ui_cancel"): Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
if Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED:
if event is InputEventMouseMotion:
rotate_y(-event.relative.x * sensitivity)
If anybody at all finds this useful or ends up using it, I’d be happy to know but once again do whatever you want with this ![]()