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.
# player.gdextends CharacterBody3D@export var walk_speed: float = 3.0@export var gravity: float = 20.0@onready var camera_pivot: Node3D = $CameraPivotfunc _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.
# camera_controller.gdextends 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 = $SpringArm3Dfunc _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.
# In your player script or RunState@export var run_speed: float = 6.0func _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.
# jump_state.gdextends State@export var jump_force: float = 8.0@export var air_control: float = 0.3 # Reduced control while airborne@onready var player: CharacterBody3D = ownerfunc 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:
# fall_state.gdextends State@export var air_control: float = 0.3@export var coyote_time: float = 0.1 # Grace period to still jump after walking off edgevar coyote_timer: float = 0.0@onready var player: CharacterBody3D = ownerfunc 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.
# 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.