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.
# 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()_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.
# 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 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.
# 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: apply an upward velocity impulse, then let gravity argue with it until you 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 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:
# 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 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.
# 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.ydrops 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.


