All Scrolls

Godot 4 3D Character Controller Tutorial: Movement, Camera, and Animation

Coding Quests/February 23, 2026/Tutorials

Godot 4 3D Character Controller Tutorial: Movement, Camera, and Animation

A third-person character controller is the foundation of most 3D action games. Get it right and everything built on top - combat, abilities, platforming - feels good. Get it wrong and no amount of content design saves the game.

This tutorial walks through building a complete third-person controller in Godot 4: movement, camera orbit, sprint, jump, and animation blending. Every piece uses CharacterBody3D and built-in Godot physics - no plugins, no hacks.

The Scene Structure

CharacterBody3D (Player)
├── CollisionShape3D
├── MeshInstance3D (or your character model)
├── AnimationPlayer
├── CameraPivot (Node3D)
│   └── SpringArm3D
│       └── Camera3D
└── StateMachine
    ├── IdleState
    ├── WalkState
    ├── RunState
    ├── JumpState
    └── FallState

Key decisions: CameraPivot is a child of the player (so it follows position) but we'll control its rotation independently. SpringArm3D handles camera collision - if the camera clips into a wall, the spring arm shortens automatically.

Basic Movement

Start with movement relative to the camera. This is the part most tutorials get wrong - they move the character relative to world axes, which means "forward" doesn't match where the camera is looking.

GDScript
# player.gd
extends CharacterBody3D
@export var walk_speed: float = 3.0
@export var gravity: float = 20.0
@onready var camera_pivot: Node3D = $CameraPivot
func _physics_process(delta: float) -> void:
# Apply gravity
if not is_on_floor():
velocity.y -= gravity * delta
# Get input relative to camera direction
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := _get_camera_relative_direction(input)
# Apply movement
velocity.x = direction.x * walk_speed
velocity.z = direction.z * walk_speed
# Rotate character to face movement direction
if direction.length() > 0.1:
var target_angle := atan2(direction.x, direction.z)
rotation.y = lerp_angle(rotation.y, target_angle, 10.0 * delta)
move_and_slide()
func _get_camera_relative_direction(input: Vector2) -> Vector3:
var cam_basis := camera_pivot.global_transform.basis
var forward := -cam_basis.z
var right := cam_basis.x
# Flatten to horizontal plane
forward.y = 0
right.y = 0
forward = forward.normalized()
right = right.normalized()
return (forward * -input.y + right * input.x).normalized()

The _get_camera_relative_direction function is the critical piece. It takes the camera's forward and right vectors, flattens them to the horizontal plane (so looking up/down doesn't affect movement direction), and converts the 2D input into a 3D world-space direction.

lerp_angle for rotation smoothing is important. Without it, the character snaps to the movement direction instantly. With it, there's a smooth turn that looks natural.

Camera Controller

The camera orbits around the player using mouse input. Vertical rotation is clamped so you can't flip the camera upside down.

GDScript
# camera_controller.gd
extends Node3D # Attached to CameraPivot
@export var mouse_sensitivity: float = 0.002
@export var min_pitch: float = -60.0
@export var max_pitch: float = 40.0
@onready var spring_arm: SpringArm3D = $SpringArm3D
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# Horizontal rotation (yaw)
rotate_y(-event.relative.x * mouse_sensitivity)
# Vertical rotation (pitch) - clamped
spring_arm.rotate_x(-event.relative.y * mouse_sensitivity)
spring_arm.rotation.x = clampf(
spring_arm.rotation.x,
deg_to_rad(min_pitch),
deg_to_rad(max_pitch)
)
if event.is_action_pressed("ui_cancel"):
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)

The camera pivot handles yaw (left/right), the spring arm handles pitch (up/down). This separation means horizontal rotation is independent of vertical - the camera doesn't roll or wobble.

SpringArm3D is doing the heavy lifting for camera collision. Set its spring_length to your desired camera distance (3-5 meters works well for most games), and Godot automatically pulls the camera closer when it would clip through geometry.

Sprint: Speed With Cost

Sprint modifies the movement speed and plays a different animation. If you have a stamina system, it drains stamina while active.

GDScript
# In your player script or RunState
@export var run_speed: float = 6.0
func _physics_process(delta: float) -> void:
var is_sprinting := Input.is_action_pressed("sprint")
var current_speed := run_speed if is_sprinting else walk_speed
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := _get_camera_relative_direction(input)
velocity.x = direction.x * current_speed
velocity.z = direction.z * current_speed
# Pick animation based on speed
if direction.length() > 0.1:
if is_sprinting:
animation_player.play("run")
else:
animation_player.play("walk")
else:
animation_player.play("idle")

Jump and Fall

Jumping is a state change: the character applies an upward velocity impulse, then gravity takes over until they land.

GDScript
# jump_state.gd
extends State
@export var jump_force: float = 8.0
@export var air_control: float = 0.3 # Reduced control while airborne
@onready var player: CharacterBody3D = owner
func enter() -> void:
player.velocity.y = jump_force
player.get_node("AnimationPlayer").play("jump")
func physics_update(delta: float) -> void:
# Apply gravity
player.velocity.y -= 20.0 * delta
# Reduced air control
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := player._get_camera_relative_direction(input)
player.velocity.x = lerpf(player.velocity.x, direction.x * player.walk_speed, air_control)
player.velocity.z = lerpf(player.velocity.z, direction.z * player.walk_speed, air_control)
player.move_and_slide()
# Transition to fall state when descending
if player.velocity.y < 0:
state_machine.transition_to($"../FallState")
# Early landing check
if player.is_on_floor():
state_machine.transition_to($"../IdleState")

The air_control parameter (0.3 means 30% of ground control) is essential for game feel. Full air control feels floaty and unrealistic. Zero air control feels stiff. 0.2-0.4 is the sweet spot for most action games.

The separate fall state handles the descending phase:

GDScript
# fall_state.gd
extends State
@export var air_control: float = 0.3
@export var coyote_time: float = 0.1 # Grace period to still jump after walking off edge
var coyote_timer: float = 0.0
@onready var player: CharacterBody3D = owner
func enter() -> void:
coyote_timer = coyote_time
player.get_node("AnimationPlayer").play("fall")
func physics_update(delta: float) -> void:
coyote_timer -= delta
player.velocity.y -= 20.0 * delta
# Coyote time jump
if Input.is_action_just_pressed("jump") and coyote_timer > 0.0:
state_machine.transition_to($"../JumpState")
return
# Air movement
var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back")
var direction := player._get_camera_relative_direction(input)
player.velocity.x = lerpf(player.velocity.x, direction.x * player.walk_speed, air_control)
player.velocity.z = lerpf(player.velocity.z, direction.z * player.walk_speed, air_control)
player.move_and_slide()
if player.is_on_floor():
state_machine.transition_to($"../IdleState")

Coyote time is a tiny window (0.1 seconds) after walking off an edge where the player can still jump. It's imperceptible consciously but makes platforming feel dramatically more responsive. Every good 3D platformer uses it.

Animation Blending With AnimationTree

For smooth transitions between animations, use an AnimationTree with a state machine blend node instead of calling AnimationPlayer.play() directly.

GDScript
# Set up in the Godot editor:
# AnimationTree → AnimationNodeStateMachine
# ├── idle (AnimationNodeAnimation)
# ├── walk (AnimationNodeAnimation)
# ├── run (AnimationNodeAnimation)
# ├── jump (AnimationNodeAnimation)
# └── fall (AnimationNodeAnimation)
# With transitions between each pair
# In code, trigger transitions:
@onready var anim_tree: AnimationTree = $AnimationTree
@onready var state_machine_playback: AnimationNodeStateMachinePlayback = anim_tree.get("parameters/playback")
func play_animation(anim_name: StringName) -> void:
state_machine_playback.travel(anim_name)

travel() uses the shortest path through the state machine graph to reach the target animation. Godot handles the blending automatically based on the transition settings you define in the editor (crossfade duration, blend type).

Putting It All Together

The final node tree with states:

CharacterBody3D (Player)
├── CollisionShape3D (capsule)
├── YourCharacterModel
│   ├── AnimationPlayer
│   └── AnimationTree
├── CameraPivot
│   └── SpringArm3D
│       └── Camera3D
└── StateMachine
    ├── IdleState    → no input, play idle
    ├── WalkState    → input detected, walk speed
    ├── RunState     → sprint held, run speed
    ├── JumpState    → jump pressed, upward velocity
    └── FallState    → not on floor, descending

State transitions:

  • Idle → Walk: Movement input detected
  • Walk → Idle: Movement input released
  • Walk → Run: Sprint pressed while moving
  • Run → Walk: Sprint released
  • Any grounded → Jump: Jump pressed while is_on_floor()
  • Jump → Fall: velocity.y < 0
  • Fall → Idle: is_on_floor()

Each state is 20-40 lines of focused code. Adding new movement abilities - dodge roll, wall run, ledge grab - means adding a new state node and defining its transitions. The existing states don't change.

Common Mistakes

Moving relative to world axes instead of camera. If pressing "W" always moves north regardless of camera angle, your controller is broken. Always transform input by the camera's orientation.

No rotation smoothing. Characters that snap instantly to the movement direction look robotic. Use lerp_angle with a speed of 8-12 for natural turning.

Constant gravity value. Use a higher gravity value than real-world physics (20-30 instead of 9.8). Real gravity feels floaty in games. Most 3D platformers use 2-3x real gravity for snappy, responsive jumps.

Camera clipping through walls. Always use SpringArm3D. It handles collision automatically and saves you from writing raycasting code.

All logic in one script. A 500-line player.gd is hard to debug and impossible to extend. A state machine with 5 states of 30 lines each is readable, testable, and extensible.

Going Further

This tutorial covers the essentials: movement, camera, sprint, jump, and animation. A complete 3D character controller also needs:

  • Combat states - attacks, combos, dodge rolls
  • Input buffering - queuing actions during animations
  • Landing state - impact recovery with animation
  • Hit reactions - stagger states when taking damage
  • Stamina system - resource management for sprint and dodge

We built a 19-lesson 3D Souls-Like Controller course that covers all of this - from basic WASD movement to a full combat system with 3-hit combos, dodge rolling with i-frames, hit detection, and death states. The first two lessons are free with no account required.

godottutorial3dcharacter-controller