There are a few player controller examples out there that I and others have relied on and nothing against the examples and tutorials given but I find many of them thanks to all the recent changes are pretty out of date or they're basic even by basic standards. I've been pondering this idea for awhile and it would be a fun little diversion from the usual stuff I'm having to deal with to play around with a simple FPS/RPG concept I've been working on and as a bonus get to spread my brand a bit as well while helping out Godot itself.
I think this should definitely be more of a thing, especially if we all explore open sourcing game genres that aren't often looked at it could potentially attract a lot of interest. Anyway, here's my code, it's loosely based off previous tutorials that have already been posted up, so credit to Garbaj etc. I've just heavily modified it so it's very similar to the mechanics of Half Life I love being able to climb ladders and crawl through vents smoothly in games and it's amazing how so few of them get it right.
I've done quite a bit of playtesting on this but let me know if I've got any bugs anywhere, not the neatest code in the world but it got the job done.
. Crouching
. Crouch Jumping
. Sprinting
. Ladder Climbing ( Create a 3D area and send a signal to the player controller for this to work )
extends CharacterBody3D
var mouseSensitivity = 0.2
var defaultMoveSpeed = 500
var sprintMoveSpeed = 700
var crouchMoveSpeed = 100
var speed = 300
var jumpVelocity = 3.8
var gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
var velocityY = 0
@onready var mouseLookNode = $MouseLookNode
@onready var playerCollisionShape = $PlayerCollisionShape
@onready var playerCamera = $MouseLookNode/PlayerCamera
@onready var playerRaycast = $MouseLookNode/PlayerCamera/PlayerRaycast
@onready var raycastCeilingCheck = $RaycastCeilingCheck
@onready var groundCheck = $RaycastGroundCheck
@export var defaultCameraHeight : Vector3
@export var crouchCameraHeight : Vector3
@onready var playerCrouchingCollisionShape = $CrouchCollisionShape
var isHittingCeiling = false
var isCrouching = false
var isSprinting = false
var isTouchingLadder = false
var horizontalVelocity
var playerRaycastCollision
func _ready():
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
playerCamera.make_current()
isCrouching = false
playerCollisionShape.set_deferred(("disabled"), false)
playerCrouchingCollisionShape.set_deferred(("disabled"), true)
func _unhandled_input(event):
if event is InputEventMouseMotion:
rotate_y(deg_to_rad(mouseSensitivity * -event.relative.x))
mouseLookNode.rotate_x(deg_to_rad(mouseSensitivity * -event.relative.y))
mouseLookNode.rotation.x = clamp(mouseLookNode.rotation.x, deg_to_rad(-89), deg_to_rad(89))
func _physics_process(delta):
if isTouchingLadder == true:
velocityY = 0
gravity = 0
velocity = Vector3.ZERO
if Input.is_action_pressed("W") and isTouchingLadder == true:
velocityY += 300 * delta
if Input.is_action_pressed("S") and isTouchingLadder == true:
velocityY -= 300 * delta
elif Input.is_action_just_pressed("Space") and isTouchingLadder == true:
velocityY = jumpVelocity
isTouchingLadder = false
elif isTouchingLadder == false:
gravity = ProjectSettings.get_setting("physics/3d/default_gravity")
horizontalVelocity = Input.get_vector("A", "D", "W", "S").normalized() * speed * delta
velocity = horizontalVelocity.x * global_transform.basis.x + horizontalVelocity.y * global_transform.basis.z
if groundCheck.is_colliding():
if Input.is_action_just_pressed("Space") and not raycastCeilingCheck.is_colliding():
velocityY = jumpVelocity
isTouchingLadder = false
elif not groundCheck.is_colliding() and isTouchingLadder == false:
velocityY -= gravity * delta
velocity.y = velocityY
move_and_slide()
if Input.is_action_pressed("Crouch") and isCrouching == false and isTouchingLadder == false:
isCrouching = true
playerCollisionShape.set_deferred(("disabled"), true)
playerCrouchingCollisionShape.set_deferred(("disabled"), false)
mouseLookNode.transform.origin = mouseLookNode.transform.origin.lerp(crouchCameraHeight, delta * 50)
speed = crouchMoveSpeed
elif Input.is_action_just_released("Crouch") and isCrouching == true and raycastCeilingCheck.is_colliding() and isTouchingLadder == false:
isCrouching = true
playerCollisionShape.set_deferred("disabled", true)
playerCrouchingCollisionShape.set_deferred("disabled", false)
mouseLookNode.transform.origin = mouseLookNode.transform.origin.lerp(crouchCameraHeight, delta * 50)
speed = crouchMoveSpeed
elif not Input.is_action_pressed("Crouch") and isCrouching == true and raycastCeilingCheck.is_colliding() and isTouchingLadder == false:
isCrouching = true
playerCollisionShape.set_deferred("disabled", true)
playerCrouchingCollisionShape.set_deferred("disabled", false)
mouseLookNode.transform.origin = mouseLookNode.transform.origin.lerp(crouchCameraHeight, delta * 50)
speed = crouchMoveSpeed
elif not Input.is_action_pressed("Crouch") and isCrouching == true and not raycastCeilingCheck.is_colliding() and isTouchingLadder == false:
isCrouching = false
playerCollisionShape.set_deferred("disabled", false)
playerCrouchingCollisionShape.set_deferred("disabled", true)
mouseLookNode.transform.origin = mouseLookNode.transform.origin.lerp(defaultCameraHeight, delta * 50)
speed = defaultMoveSpeed
if Input.is_action_pressed("Shift") and isCrouching == false and groundCheck.is_colliding() and not raycastCeilingCheck.is_colliding():
isSprinting = true
speed = sprintMoveSpeed
elif Input.is_action_just_released("Shift") and isCrouching == false and groundCheck.is_colliding() and not raycastCeilingCheck.is_colliding():
isSprinting = false
speed = defaultMoveSpeed
#func _on_ladder_area_3d_body_entered(body):
# isTouchingLadder = true
#
#func _on_ladder_area_3d_body_exited(body):
# isTouchingLadder = false
I also plan on posting up a project on github at some point after I do some thorough testing.
Hierarchy Setup:

General collision and node positioning:

Character body setup:

A couple of notes, if the max angle is too low your character body will be blocked if you try to crouch through vents even if the obstacle in question is tiny compared to the collision shape you're using. Have the camera positions match the centre of your collisions for the sake of having an accurate view of what the player is doing and also this is a mistake I've made myself make sure that before runtime all rotations are set to 0 otherwise you will encounter gimbal lock with the rotation clamp.
Have fun! Hope this helps people wanting to find first person stuff! Everything posted here is free and open source for people to use.