> ## Documentation Index
> Fetch the complete documentation index at: https://docs.summerengine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Default third person controller (Character movement skill)

> default-third-person-controller.gd from the Character movement skill in the Summer Engine skill library. Player controllers for 2D and 3D, plus the standard third-person controller for Summer humanoids.

<Note>Part of the [`character-movement` skill](/skills/character-movement). The skill links here when your agent needs the detail.</Note>

```gdscript default-third-person-controller.gd theme={null}
class_name SummerThirdPersonController
extends CharacterBody3D

## Canonical controller for a playable Summer humanoid character package.
## Copy this file unchanged first. Customize the copy only when the user asks.

const REQUIRED_ROLES: Array[StringName] = [&"idle", &"walk", &"run", &"jump"]
const REQUIRED_ACTIONS: Array[StringName] = [
	&"move_left",
	&"move_right",
	&"move_forward",
	&"move_back",
	&"jump",
	&"sprint",
	&"ui_cancel",
]

@export_file("*.json") var character_manifest_path: String

@export_category("Movement")
@export var walk_speed: float = 4.0
@export var run_speed: float = 7.0
@export var acceleration: float = 24.0
@export var jump_velocity: float = 5.5
@export var turn_speed: float = 12.0
@export var floor_snap_distance: float = 0.3
@export var floor_stick_velocity: float = 0.5

@export_category("Camera")
@export var mouse_sensitivity: float = 0.002
@export var camera_height: float = 1.5
@export var camera_distance: float = 4.5
@export_flags_3d_physics var camera_collision_mask: int = 1
@export var camera_pitch_degrees: float = -20.0

@export_category("Respawn")
@export var respawn_below_y: float = -15.0

@export_category("Required Nodes")
@export_node_path("Node3D") var visual_root_path: NodePath = ^"CharacterRotationRoot"
@export_node_path("Node3D") var camera_yaw_path: NodePath = ^"CameraYaw"
@export_node_path("Node3D") var camera_pitch_path: NodePath = ^"CameraYaw/CameraPitch"
@export_node_path("SpringArm3D") var spring_arm_path: NodePath = ^"CameraYaw/CameraPitch/SpringArm3D"
@export_node_path("AnimationPlayer") var animation_player_path: NodePath = ^"CharacterRotationRoot/Character/Visual/AnimationPlayer"

@onready var _visual_root: Node3D = get_node(visual_root_path) as Node3D
@onready var _camera_yaw: Node3D = get_node(camera_yaw_path) as Node3D
@onready var _camera_pitch: Node3D = get_node(camera_pitch_path) as Node3D
@onready var _spring_arm: SpringArm3D = get_node(spring_arm_path) as SpringArm3D
@onready var _animation_player: AnimationPlayer = get_node(animation_player_path) as AnimationPlayer

var _gravity: float = 0.0
var _spawn_position: Vector3
var _clips_by_role: Dictionary = {}
var _active_role: StringName = &""


func _ready() -> void:
	_validate_scene_contract()
	_gravity = float(ProjectSettings.get_setting("physics/3d/default_gravity"))
	_spawn_position = global_position
	floor_snap_length = floor_snap_distance
	_camera_yaw.position.y = camera_height
	_camera_pitch.rotation = Vector3(deg_to_rad(camera_pitch_degrees), 0.0, 0.0)
	_spring_arm.spring_length = camera_distance
	_spring_arm.collision_mask = camera_collision_mask
	_spring_arm.add_excluded_object(get_rid())
	_configure_character_animation()
	_play_role(&"idle")
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed(&"ui_cancel"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
		return
	if event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
	if Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
		return
	if event is InputEventMouseMotion:
		_camera_yaw.rotate_y(-event.screen_relative.x * mouse_sensitivity)


func _physics_process(delta: float) -> void:
	var was_on_floor := is_on_floor()
	if was_on_floor:
		# A small downward velocity keeps the body snapped to flat and gently
		# descending floors instead of producing a one-frame false airborne state.
		velocity.y = -floor_stick_velocity
	else:
		velocity.y -= _gravity * delta
	if Input.is_action_just_pressed(&"jump") and was_on_floor:
		velocity.y = jump_velocity

	var input_vector: Vector2 = Input.get_vector(
		&"move_left",
		&"move_right",
		&"move_forward",
		&"move_back"
	)
	var camera_forward: Vector3 = -_camera_yaw.global_transform.basis.z
	var camera_right: Vector3 = _camera_yaw.global_transform.basis.x
	camera_forward.y = 0.0
	camera_right.y = 0.0
	camera_forward = camera_forward.normalized()
	camera_right = camera_right.normalized()
	var movement_direction: Vector3 = (
		camera_right * input_vector.x + camera_forward * -input_vector.y
	)
	if movement_direction.length_squared() > 1.0:
		movement_direction = movement_direction.normalized()

	var is_sprinting: bool = Input.is_action_pressed(&"sprint")
	var target_speed: float = run_speed if is_sprinting else walk_speed
	var target_velocity: Vector3 = movement_direction * target_speed
	velocity.x = move_toward(velocity.x, target_velocity.x, acceleration * delta)
	velocity.z = move_toward(velocity.z, target_velocity.z, acceleration * delta)

	move_and_slide()
	_turn_visual_toward(movement_direction, delta)
	_update_animation_state(is_sprinting)
	if global_position.y < respawn_below_y:
		global_position = _spawn_position
		velocity = Vector3.ZERO
		_play_role(&"idle")


func _validate_scene_contract() -> void:
	assert(_visual_root != null, "Missing CharacterRotationRoot")
	assert(_camera_yaw != null, "Missing CameraYaw")
	assert(_camera_pitch != null, "Missing CameraPitch")
	assert(_spring_arm != null, "Missing SpringArm3D")
	assert(_animation_player != null, "Missing character AnimationPlayer")
	assert(character_manifest_path != "", "Set character_manifest_path")
	for action in REQUIRED_ACTIONS:
		assert(InputMap.has_action(action), "Missing input action: %s" % action)


func _configure_character_animation() -> void:
	var file := FileAccess.open(character_manifest_path, FileAccess.READ)
	assert(file != null, "Missing character manifest: %s" % character_manifest_path)
	var manifest = JSON.parse_string(file.get_as_text())
	assert(manifest is Dictionary, "Invalid character manifest JSON")
	assert(manifest.get("status") == "ready", "Character package is not ready")
	assert(
		manifest.get("normalization", {}).get("forwardAxis") == "-Z",
		"Character package forward axis must be -Z"
	)

	for entry in manifest.get("animations", []):
		if not entry is Dictionary:
			continue
		var role := StringName(String(entry.get("semanticRole", "")).to_lower())
		if role not in REQUIRED_ROLES:
			continue
		var clip := StringName(String(entry.get("name", "")))
		assert(clip != &"", "Requested character role has no clip: %s" % role)
		assert(not _clips_by_role.has(role), "Ambiguous character role: %s" % role)
		assert(_animation_player.has_animation(clip), "Manifest clip is not playable: %s" % clip)
		_clips_by_role[role] = clip

	for role in REQUIRED_ROLES:
		assert(_clips_by_role.has(role), "Missing requested character role: %s" % role)
	for role in [&"idle", &"walk", &"run"]:
		_animation_player.get_animation(_clips_by_role[role]).loop_mode = Animation.LOOP_LINEAR
	_animation_player.get_animation(_clips_by_role[&"jump"]).loop_mode = Animation.LOOP_NONE


func _update_animation_state(is_sprinting: bool) -> void:
	var horizontal_speed := Vector2(velocity.x, velocity.z).length()
	var next_role: StringName
	if not is_on_floor():
		next_role = &"jump"
	elif horizontal_speed <= 0.05:
		next_role = &"idle"
	elif is_sprinting:
		next_role = &"run"
	else:
		next_role = &"walk"
	_play_role(next_role)


func _play_role(role: StringName) -> void:
	assert(_clips_by_role.has(role), "Missing requested character role: %s" % role)
	if role == _active_role:
		return
	_animation_player.play(_clips_by_role[role])
	# Apply the first keyed pose immediately. This prevents a visible bind-pose
	# frame while the AnimationPlayer waits for its next process tick.
	_animation_player.advance(0.0)
	_active_role = role


func _turn_visual_toward(direction: Vector3, delta: float) -> void:
	if direction.length_squared() <= 0.0001:
		return
	var local_direction: Vector3 = global_transform.basis.inverse() * direction
	var target_yaw := atan2(-local_direction.x, -local_direction.z)
	var weight := 1.0 - exp(-turn_speed * delta)
	_visual_root.rotation.y = lerp_angle(_visual_root.rotation.y, target_yaw, weight)
```

***

Source: [`library/skills/character-movement/references/default-third-person-controller.gd`](https://github.com/SummerEngine/summer/blob/main/library/skills/character-movement/references/default-third-person-controller.gd), rendered from `summer-engine@4.3.0` on npm.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.