A stair climb up/climb down script you can throw on your CharacterBody3D for cylinder or box colliders that works really well

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 :slight_smile:

2 Likes

Neat.

How come capsule collision shapes won’t work?


Also, I would modify this line:

if collider is CSGMesh3D or StaticBody3D:

So that it doesn’t work with CSGMesh3D objects, because they are awful for performance. You should convert them to MeshInstance3D objects anyway and add them to a StaticBody3D. Otherwise you will see performance issues once you have a few on screen.

You raise good points about csg meshes. I could change it and save the unnecessary cast, i just quickly prototyped some steps to test it using them.

Capsule collision shapes don’t reliably read the normal of the step due to the curved bottom hitting the stair corner, don’t seem to reliably carry enough velocity to push you up the step by just altering Y position.