Coding Quests
The Scroll Library
Tutorials

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

February 23, 2026Updated June 11, 20267 min read

A third-person character controller is the first thing I build in any 3D project, and the thing I've rebuilt the most. Get it right and everything on top (combat, abilities, platforming) inherits the good feel. Get it wrong and no amount of content design saves the game.

This tutorial builds a complete third-person controller in Godot 4: camera-relative movement, orbit camera, sprint, jump, and animation blending. Everything runs on 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

Two decisions worth calling out. CameraPivot is a child of the player, so it follows position, but we'll control its rotation independently. And SpringArm3D handles camera collision for free: if the camera would clip into a wall, the spring arm shortens automatically.

Camera-Relative Movement

Start with movement relative to the camera. This is the part most tutorials get wrong. They move the character along world axes, so pressing forward sends you north no matter where the camera points, and the controller feels broken from minute one.

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()

_get_camera_relative_direction is the critical function. It takes the camera's forward and right vectors, flattens them onto the horizontal plane so looking up or down doesn't affect movement, then converts the 2D input into a world-space direction.

The lerp_angle matters too. Without it, the character snaps to face the movement direction instantly, which reads as robotic. With it you get a smooth, natural turn. A lerp speed of 10 is a good default. Drop it lower for heavier-feeling characters.

The Third-Person Camera Controller

The camera orbits the player with mouse input. Vertical rotation gets clamped so you can't flip the camera upside down and stare at the world from under the floor.

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 pivot handles yaw, the spring arm handles pitch. Splitting them keeps horizontal rotation independent of vertical, so the camera never rolls or wobbles.

SpringArm3D is doing the heavy lifting on collision. Set its spring_length to your desired camera distance (3 to 5 meters suits most third-person games) and Godot automatically pulls the camera closer when it would clip through geometry. That's a whole raycasting system you don't have to write.

Sprint: Speed With Cost

Sprint swaps the movement speed and the animation. If you've got a stamina system, this is where it drains.

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: apply an upward velocity impulse, then let gravity argue with it until you 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 value (0.3 means 30 percent of ground control) is pure game feel. Full air control feels floaty. Zero feels like steering a brick. Somewhere between 0.2 and 0.4 is the sweet spot for most action games, and you'll know yours within five minutes of testing.

The separate fall state handles the way down:

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 here) after walking off an edge where a jump still counts. Nobody consciously notices it. Everybody feels it. Every 3D platformer you remember as "tight" is doing this.

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, and Godot handles the blending automatically based on the transition settings you define in the editor (crossfade duration, blend type). You set up transitions once, then everything looks smooth without another line of code.

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

The transition rules are simple:

  • Idle to Walk: movement input detected
  • Walk to Idle: input released
  • Walk to Run: sprint held while moving
  • Run to Walk: sprint released
  • Any grounded state to Jump: jump pressed while is_on_floor() is true
  • Jump to Fall: velocity.y drops below zero
  • Fall to Idle: is_on_floor() is true again

Each state is 20 to 40 lines of focused code. Adding a dodge roll, wall run, or ledge grab later means adding one new state node and wiring its transitions. The existing states don't change. That's the whole argument for doing it this way.

Common Character Controller Mistakes

Moving along world axes instead of camera axes is the big one. If pressing W always heads north no matter where the camera looks, your controller is broken. Always transform input by the camera's orientation.

Snapping rotation is the next giveaway. Characters that whip instantly to the movement direction look robotic. Use lerp_angle with a speed of 8 to 12 for natural turning.

Real-world gravity feels wrong in games. Use 20 to 30 instead of 9.8. Real gravity feels floaty at game scale, and most 3D platformers run two to three times the real value because snappy jumps beat realistic ones every single time.

Skipping SpringArm3D and letting the camera clip through walls. It handles collision automatically and saves you from writing raycasting code. Use it.

And the classic: one giant script. A 500 line player.gd is hard to debug and impossible to extend. Five states at 30 lines each is readable, testable, and a codebase you'll actually want to open next month.

Going Further

This covers the foundation: movement, camera, sprint, jump, animation. A combat-ready controller also needs attack states and combos, input buffering so actions queue during animations, a landing state with impact recovery, hit reactions when you take damage, and a stamina system so sprint and dodge cost something. I walk through how all of those pieces fit together in the souls-like architecture guide.

Or build it hands-on: our 19-lesson 3D Souls-Like Controller quest goes 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, no account required. Your capsule is waiting.

FAQ

How do I make movement camera-relative in Godot?

Take the camera's basis, flatten its forward and right vectors onto the horizontal plane, normalize them, then combine them with your input vector. That's exactly what _get_camera_relative_direction does above. The mistake to avoid is using the raw basis without zeroing the Y components, which makes movement speed change whenever the camera pitches up or down.

Should I use CharacterBody3D or RigidBody3D for the player?

CharacterBody3D, for almost every character controller. You set velocity and call move_and_slide(), which gives you direct, predictable control plus helpers like is_on_floor(). RigidBody3D is for objects the physics engine should drive. Using it for a player means fighting the solver for control, and you usually lose.

Does this controller work with a gamepad?

Yes. Input.get_vector reads whatever you bind in the Input Map, so adding left-stick bindings to the four move actions covers movement with zero code changes. The camera needs one tweak: bind the right stick to actions and apply rotation every frame in _process, because InputEventMouseMotion only fires for the mouse.

godottutorial3dcharacter-controller

Stop reading. Start building.

Beat the demo boss by writing real Godot code, then build this for real in the 3D Roguelike Controller Quest.

Free, and no card needed. Built by a real person, with new quests every month.

Get the next Godot build in your inbox

New quests, project breakdowns, and game-dev tips. Free, no spam, unsubscribe anytime.

Written by Coding Quests

We teach Godot 4 by making you build complete systems: inventories, save systems, action roguelike controllers, enemy AI. The scrolls are free. The quests are where it sticks.