Script
extends CharacterBody3D
#region declarations
@export var active: bool = true
@export var camera: Camera3D
@export var camera_arm: SpringArm3D
@export var collision_shape: CollisionShape3D
@export var mesh_transform_node: Node3D
@export var terrestrial: bool = true
@export var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
@export var terminal_velocity: float = -1111.0
@export var move_speed: float = 4.0
@export var acceleration: float = 0.1
@export var deceleration: float = 0.1
@export var jump_strength: float = 6.0
@export var always_face_forward: bool = true
@export var face_move_forward: bool = true
@export var min_camera_rotation_degrees_v: float = -80.0
@export var max_camera_rotation_degrees_v: float = 80.0
@onready var min_camera_rotation_radians_v: float = deg_to_rad(min_camera_rotation_degrees_v)
@onready var max_camera_rotation_radians_v: float = deg_to_rad(max_camera_rotation_degrees_v)
var camera_zoom: float = 5.0
var max_camera_zoom: float = 5.0
var min_camera_zoom: float = 1.0
var camera_zoom_step: float = 1.0
var camera_lerp_speed: float = 2.0
var camera_offset: float = 0.5
var input_keys: Dictionary = {
"forward": "W",
"back": "S",
"left": "A",
"right": "D",
"jump": "SPACE",
}
# input device config
@export var mouse_sensitivity_h: float = 0.01
@export var mouse_sensitivity_v: float = 0.01
#endregion
#region overrides
func _ready() -> void:
setup_input_map()
if not camera or not camera_arm:
camera = spawn_debug_camera()
camera_arm = camera.get_parent().get_parent()
if not collision_shape:
collision_shape = spawn_debug_collision_shape()
if not mesh_transform_node:
mesh_transform_node = spawn_debug_mesh()
set_active(active)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
set_active(not active)
if not active:
return
if event is InputEventMouseMotion:
camera_arm.rotation.y -= event.relative.x * mouse_sensitivity_h
camera_arm.rotation.y = wrapf(camera_arm.rotation.y, 0.0, TAU)
camera_arm.rotation.x -= event.relative.y * mouse_sensitivity_v
camera_arm.rotation.x = clampf(
camera_arm.rotation.x,
min_camera_rotation_radians_v,
max_camera_rotation_radians_v
)
if event.is_action_pressed("jump") and terrestrial and is_on_floor():
velocity.y = jump_strength
if event.is_action_pressed("zoom_out"):
camera_zoom = min(max_camera_zoom, camera_zoom + camera_zoom_step)
if event.is_action_pressed("zoom_in"):
camera_zoom = max(min_camera_zoom, camera_zoom - camera_zoom_step)
func _physics_process(delta: float) -> void:
if camera_arm.spring_length != camera_zoom:
camera_arm.spring_length = move_toward(camera_arm.spring_length, camera_zoom, camera_lerp_speed * delta)
if terrestrial and not is_on_floor() and velocity.y > terminal_velocity:
velocity.y -= gravity * delta
var angle: float = camera_arm.global_rotation.y
var forward_direction := Vector3.FORWARD.rotated(Vector3.UP, angle)
var horizontal_view_basis: Basis = get_horizontal_basis(forward_direction)
var move_direction: Vector3 = horizontal_input_direction(
horizontal_view_basis,
get_input_vector("forward", "back", "left", "right")
)
if move_direction == Vector3.ZERO:
velocity = lerp(velocity, Vector3(0.0, velocity.y, 0.0), deceleration)
else:
var final_move_speed: float = move_speed
var final_direction: Vector3 = (move_direction * final_move_speed) + Vector3(0.0, velocity.y, 0.0)
velocity = lerp(velocity, final_direction, acceleration)
move_and_slide()
if always_face_forward or move_direction != Vector3.ZERO:
var view_basis: Basis = get_horizontal_basis(move_direction) if face_move_forward else horizontal_view_basis
turn(view_basis)
#endregion
#region input
func set_active(state: bool) -> void:
active = state
if active:
camera.current = true
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
func get_horizontal_basis(direction: Vector3) -> Basis:
var b = Basis(
Vector3.UP,
Vector3.FORWARD.signed_angle_to(direction, Vector3.UP)
)
return b
func turn(desired_basis: Basis, rotation_strength: float = 0.2):
if global_transform.basis != desired_basis:
mesh_transform_node.global_basis = lerp_rotation(mesh_transform_node.global_basis, desired_basis, rotation_strength)
static func lerp_rotation(from_basis: Basis, to_basis: Basis, rotation_strength : float) -> Basis:
var from := Quaternion(from_basis)
var to := Quaternion(to_basis)
var rot = from.slerp(to, rotation_strength)
var result := Basis(rot)
return result
static func get_input_vector(forward_input: String, back_input: String, left_input: String, right_input: String) -> Vector2:
var input_direction: Vector2 = Input.get_vector(left_input, right_input, forward_input, back_input)
return input_direction
static func horizontal_input_direction(view_basis: Basis, input_direction: Vector2) -> Vector3:
var vector3_input_direction := Vector3(input_direction.x, 0, input_direction.y)
var result: Vector3 = (view_basis * vector3_input_direction).normalized()
return result
#endregion
#region debug nodes
func spawn_debug_camera(height: float = 1.5, distance: float = camera_zoom, offset: float = camera_offset) -> Camera3D:
var transform_node := Node3D.new()
add_child(transform_node)
var camera_spring := SpringArm3D.new()
camera_spring.spring_length = distance
transform_node.add_child(camera_spring)
camera_spring.position.y = height
var offset_spring := SpringArm3D.new()
offset_spring.spring_length = offset
camera_spring.add_child(offset_spring)
offset_spring.rotation_degrees.y = 90.0
var new_camera := Camera3D.new()
offset_spring.add_child(new_camera)
new_camera.rotation_degrees.y = -90.0
return new_camera
func spawn_debug_collision_shape(height: float = 1.75, radius: float = 0.15) -> CollisionShape3D:
var collision_node := CollisionShape3D.new()
collision_node.shape = CapsuleShape3D.new()
collision_node.shape.height = height
collision_node.shape.radius = radius
add_child(collision_node)
collision_node.position.y = height / 2.0
return collision_node
func spawn_debug_mesh(height: float = 1.75, radius: float = 0.15, color := Color.GREEN, pointer_color := Color.RED) -> Node3D:
var transform_node := Node3D.new()
add_child(transform_node)
var mesh_material := StandardMaterial3D.new()
mesh_material.albedo_color = color
var mesh_node := MeshInstance3D.new()
mesh_node.mesh = CapsuleMesh.new()
mesh_node.mesh.height = height
mesh_node.mesh.radius = radius
mesh_node.mesh.set_material(mesh_material)
transform_node.add_child(mesh_node)
mesh_node.position.y = height / 2.0
var pointer_mesh_material := StandardMaterial3D.new()
pointer_mesh_material.albedo_color = pointer_color
var pointer_mesh_node := MeshInstance3D.new()
pointer_mesh_node.mesh = PrismMesh.new()
pointer_mesh_node.mesh.size = Vector3(radius * 2.0, radius * 2.0, radius)
pointer_mesh_node.mesh.set_material(pointer_mesh_material)
transform_node.add_child(pointer_mesh_node)
pointer_mesh_node.position = Vector3(0.0, height - (height / 5.0), radius * -2.0)
pointer_mesh_node.rotation_degrees.x = -90.0
return transform_node
#endregion
#region input_map
func setup_input_map() -> void:
for action_name: String in input_keys.keys():
setup_input(action_name, input_keys[action_name])
setup_scroll_input("zoom_in", MOUSE_BUTTON_WHEEL_UP)
setup_scroll_input("zoom_out", MOUSE_BUTTON_WHEEL_DOWN)
static func setup_input(action_name: String, default_key: String) -> void:
if not InputMap.has_action(action_name):
InputMap.add_action(action_name)
if InputMap.action_get_events(action_name).is_empty():
var event := InputEventKey.new()
event.keycode = OS.find_keycode_from_string(default_key)
InputMap.action_add_event(action_name, event)
static func setup_scroll_input(action: String, button: MouseButton) -> void:
if not InputMap.has_action(action):
InputMap.add_action(action)
if InputMap.action_get_events(action).is_empty():
var event := InputEventMouseButton.new()
event.button_index = button
InputMap.action_add_event(action, event)
#endregion
Breakdown
This script implements a fully functional Third Person Controller for Godot 4 using CharacterBody3D.
Features:
-
Third-person orbit camera
-
Spring-arm camera collision
-
Mouse-controlled camera rotation
-
Camera zoom
-
WASD movement relative to camera
-
Smooth character rotation
-
Optional “always face camera direction” behaviour
-
Jumping and gravity
-
Automatic debug camera, mesh, and collision generation
The script is designed so developers can drop it into a project and immediately control a character, while still allowing deep customization through exported variables.
Basic Setup
-
Create a CharacterBody3D node.
-
Attach the script.
If no camera, mesh, or collision shape is assigned, the script generates debug versions automatically, allowing immediate testing.
Core Components
The controller revolves around four nodes:
| Node | Purpose |
|---|---|
| CharacterBody3D | Handles physics and movement |
| SpringArm3D | Positions the camera behind the player |
| Camera3D | Renders the scene |
| Mesh Node | Rotates visually to face movement |
The SpringArm3D automatically shortens if something blocks the camera, preventing clipping through walls.
Exported Variables
These appear in the Inspector, making the controller easy to tune.
Core References
@export var camera: Camera3D@export var camera_arm: SpringArm3D@export var collision_shape: CollisionShape3D@export var mesh_transform_node: Node3DThese allow you to assign existing scene nodes.
If they are missing, the script spawns debug versions.
Movement Settings
@export var terrestrial: boolDetermines whether gravity affects the player.
@export var gravity@export var terminal_velocityGravity is pulled from project physics settings.
Terminal velocity prevents unlimited falling acceleration.
@export var move_speed@export var acceleration@export var decelerationThese control player speed and how quickly the character starts and stops moving.
Jumping
@export var jump_strengthJump velocity applied when pressing the jump input.
Jumping only occurs if:
is_on_floor()Character Facing Behaviour
Two options determine how the character rotates.
@export var always_face_forwardIf true, the character constantly faces the camera direction.
@export var face_move_forwardIf enabled, the character rotates toward movement direction instead.
This is common in action games where the character turns while running.
Camera System
The camera uses two SpringArm3D nodes.
Structure:
└ SpringArm3D (main orbit)
└ SpringArm3D (horizontal offset)
└ Camera3D
This provides:
-
camera orbit
-
camera horizontal offset
-
automatic obstacle avoidance
Camera Rotation
inside _unhandled_input(event) Mouse movement rotates the camera arm:
Horizontal:
camera_arm.rotation.y -= event.relative.x * mouse_sensitivity_hVertical:
camera_arm.rotation.x -= event.relative.y * mouse_sensitivity_vVertical rotation is clamped to avoid flipping:
camera_arm.rotation.x = clampf(camera_arm.rotation.x, min_camera_rotation_radians_v, max_camera_rotation_radians_v)Camera Zoom
Zoom is controlled with the scroll wheel.
if event.is_action_pressed("scroll_down"):
camera_zoom = min(max_camera_zoom, camera_zoom + camera_zoom_step)
if event.is_action_pressed("scroll_up"):
camera_zoom = max(min_camera_zoom, camera_zoom - camera_zoom_step)
The camera arm smoothly interpolates to the new zoom level:
camera_arm.spring_length = move_toward(camera_arm.spring_length, camera_zoom, camera_lerp_speed * delta)This prevents sudden camera jumps.
Movement System
Movement occurs inside _physics_process().
Gravity
Gravity applies if the character is not on the ground.
velocity.y -= gravity * deltaVelocity is capped by terminal_velocity.
Camera-Relative Movement
Movement direction is calculated relative to the camera.
First the camera’s horizontal rotation is extracted:
var angle = camera_arm.global_rotation.yThen a forward vector is generated:
Vector3.FORWARD.rotated(Vector3.UP, angle)This means:
Pressing W moves the character in the direction the camera faces, not world north.
Input Conversion
Input is collected using:
Input.get_vector()This returns a normalized direction vector from WASD input.
It is then converted into a 3D direction using the camera’s orientation.
Acceleration and Deceleration
Movement speed changes smoothly using interpolation:
velocity = lerp(velocity, final_direction, acceleration)If no input exists:
velocity = lerp(velocity, Vector3(0.0, velocity.y, 0.0), deceleration)This creates natural movement instead of instant velocity changes.
Character Rotation
Unlike first-person controllers, the player mesh must rotate independently of the physics body.
The function:
turn(desired_basis)rotates the visual mesh toward the desired direction.
Instead of snapping instantly, it uses quaternion spherical interpolation (slerp).
This avoids sudden rotations and produces smooth character turning.
Reference:
https://docs.godotengine.org/en/stable/classes/class_quaternion.html
Why Quaternions Are Used
Rotations are interpolated using quaternions instead of Euler angles because they avoid:
-
gimbal lock
-
axis snapping
-
interpolation artifacts
The script converts the mesh basis into quaternions, interpolates, then converts back.
Helper Functions
get_horizontal_basis()
Creates a rotation basis aligned with a given horizontal direction.
Used to align the character with the camera or movement direction.
horizontal_input_direction()
Transforms a 2D input vector into a 3D world direction.
This allows movement relative to camera orientation.
lerp_rotation()
Smoothly interpolates between two rotations using quaternion slerp.
Automatic Debug Components
To make the controller usable instantly, the script generates fallback nodes.
Debug Camera
Creates:
-
camera pivot
-
orbit spring arm
-
offset spring arm
-
camera
This structure supports orbiting and obstacle avoidance.
Debug Collision
Creates a CapsuleShape3D, which is the standard collider used in most character controllers.
Capsules slide smoothly along geometry and avoid snagging on edges.
Debug Mesh
A capsule mesh plus a small directional pointer are created.
The pointer indicates which way the character is facing, useful during development.
Why This Controller Is More Complex Than First Person
Third-person controllers must manage three independent systems:
-
Camera orbit and zoom
-
Movement relative to camera direction
-
Character mesh orientation
These systems must remain synchronized while still allowing smooth transitions.
-
physics body controls movement
-
camera arm controls view
-
mesh node controls visual rotation
This separation prevents common issues like camera jitter and rotation snapping.
com
full time NPC, part time ByteBloomer software developer



