Godot Third Person Controller (Copy-Paste Ready)
comMar 12, 20268 min read

Godot Third Person Controller (Copy-Paste Ready)

The script is designed so developers can drop it into a project and immediately control a character

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

  1. Create a CharacterBody3D node.

  2. 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: Node3D

These allow you to assign existing scene nodes.

If they are missing, the script spawns debug versions.


Movement Settings

@export var terrestrial: bool

Determines whether gravity affects the player.

@export var gravity
@export var terminal_velocity

Gravity is pulled from project physics settings.

Terminal velocity prevents unlimited falling acceleration.

@export var move_speed
@export var acceleration
@export var deceleration

These control player speed and how quickly the character starts and stops moving.


Jumping

@export var jump_strength

Jump 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_forward

If true, the character constantly faces the camera direction.

@export var face_move_forward

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

Player
└ 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_h

Vertical:

camera_arm.rotation.x -= event.relative.y * mouse_sensitivity_v

Vertical 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 * delta

Velocity 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.y

Then 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).

Quaternion.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.

Basis → Quaternion → slerp → Basis

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:

  1. Camera orbit and zoom

  2. Movement relative to camera direction

  3. 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.

C

com

full time NPC, part time ByteBloomer software developer

Comments