2025/09/23

- Type
- Learning Resource
- Format
- Video
- Version
- Godot 4.x
- Subject Tags
- Downloadable Demo
- Bonus
- Created
- Updated
- 2025/08/04
- 2025/09/23
A 3D character controller is the foundation of most 3D games and one of the three C's of game design: Character, Camera, and Controls. It's what defines how your character moves, jumps, and interacts with the game world.
In this free tutorial, you'll learn how to code a 3D third-person character controller in Godot 4, complete with smooth ground movement, jump and fall mechanics, and responsive controls perfect for third-person games.
It's a solid foundation you can study and build upon, whether you're making a platformer, an action-adventure game, or any other 3D game with a playable character.
This tutorial assumes that you have:
If you're just getting started, here are some resources to help you learn the basics of Godot and GDScript you'll need:
Below you can find the complete code at different checkpoints in the video. You can use it to compare your code against it if you get stuck.
This is the complete code for the script at around 15:30 in the video:
extends CharacterBody3D
@export_group("Camera")
@export_range(0.0, 1.0) var mouse_sensitivity := 0.25
@export var tilt_upper_limit := PI / 3.0
@export var tilt_lower_limit := -PI / 6.0
var _camera_input_direction := Vector2.ZERO
@onready var _camera_pivot: Node3D = %CameraPivot
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed("left_click"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
var is_camera_motion := (
event is InputEventMouseMotion and
Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
)
if is_camera_motion:
_camera_input_direction = event.screen_relative * mouse_sensitivity
func _physics_process(delta: float) -> void:
_camera_pivot.rotation.x += _camera_input_direction.y * delta
_camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, tilt_lower_limit, tilt_upper_limit)
_camera_pivot.rotation.y -= _camera_input_direction.x * delta
_camera_input_direction = Vector2.ZERO
Here's the complete code for the script at around 24:00 in the video:
extends CharacterBody3D
@export_group("Camera")
@export_range(0.0, 1.0) var mouse_sensitivity := 0.25
@export var tilt_upper_limit := PI / 3.0
@export var tilt_lower_limit := -PI / 8.0
@export_group("Movement")
@export var move_speed := 8.0
@export var acceleration := 20.0
var _camera_input_direction := Vector2.ZERO
@onready var _camera_pivot: Node3D = %CameraPivot
@onready var _camera: Camera3D = %Camera3D
func _input(event: InputEvent) -> void:
if event.is_action_pressed("left_click"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
func _unhandled_input(event: InputEvent) -> void:
var is_camera_motion := (
event is InputEventMouseMotion and
Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
)
if is_camera_motion:
_camera_input_direction = event.screen_relative * mouse_sensitivity
func _physics_process(delta: float) -> void:
_camera_pivot.rotation.x += _camera_input_direction.y * delta
_camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, tilt_lower_limit, tilt_upper_limit)
_camera_pivot.rotation.y -= _camera_input_direction.x * delta
_camera_input_direction = Vector2.ZERO
var raw_input := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var forward := _camera.global_basis.z
var right := _camera.global_basis.x
var move_direction := forward * raw_input.y + right * raw_input.x
move_direction.y = 0.0
move_direction = move_direction.normalized()
velocity = velocity.move_toward(move_direction * move_speed, acceleration * delta)
move_and_slide()
Here's the complete code for the script at 28:20:
extends CharacterBody3D
@export_group("Movement")
@export var move_speed := 8.0
@export var acceleration := 20.0
@export var rotation_speed := 12.0
@export_group("Camera")
@export_range(0.0, 1.0) var mouse_sensitivity := 0.25
@export var tilt_upper_limit := PI / 3.0
@export var tilt_lower_limit := -PI / 8.0
var _camera_input_direction := Vector2.ZERO
var _last_movement_direction := Vector3.BACK
@onready var _camera_pivot: Node3D = %CameraPivot
@onready var _camera: Camera3D = %Camera3D
@onready var _skin: SophiaSkin = %SophiaSkin
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed("left_click"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
var is_camera_motion := (
event is InputEventMouseMotion and
Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
)
if is_camera_motion:
_camera_input_direction = event.screen_relative * mouse_sensitivity
func _physics_process(delta: float) -> void:
_camera_pivot.rotation.x += _camera_input_direction.y * delta
_camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, tilt_lower_limit, tilt_upper_limit)
_camera_pivot.rotation.y -= _camera_input_direction.x * delta
_camera_input_direction = Vector2.ZERO
var raw_input := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var forward := _camera.global_basis.z
var right := _camera.global_basis.x
var move_direction := forward * raw_input.y + right * raw_input.x
move_direction.y = 0.0
move_direction = move_direction.normalized()
velocity = velocity.move_toward(move_direction * move_speed, acceleration * delta)
move_and_slide()
if move_direction.length() > 0.2:
_last_movement_direction = move_direction
var target_angle := Vector3.BACK.signed_angle_to(_last_movement_direction, Vector3.UP)
_skin.global_rotation.y = lerp_angle(_skin.rotation.y, target_angle, rotation_speed * delta)
var ground_speed := velocity.length()
if ground_speed > 0.0:
_skin.move()
else:
_skin.idle()
Here's the complete code for the script at the end of the video:
extends CharacterBody3D
@export_group("Movement")
@export var move_speed := 8.0
@export var acceleration := 20.0
@export var rotation_speed := 12.0
@export var jump_impulse := 12.0
@export_group("Camera")
@export_range(0.0, 1.0) var mouse_sensitivity := 0.25
@export var tilt_upper_limit := PI / 3.0
@export var tilt_lower_limit := -PI / 8.0
var _camera_input_direction := Vector2.ZERO
var _last_movement_direction := Vector3.BACK
var _gravity := -30.0
@onready var _camera_pivot: Node3D = %CameraPivot
@onready var _camera: Camera3D = %Camera3D
@onready var _skin: SophiaSkin = %SophiaSkin
func _input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
elif event.is_action_pressed("left_click"):
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _unhandled_input(event: InputEvent) -> void:
var is_camera_motion := (
event is InputEventMouseMotion and
Input.get_mouse_mode() == Input.MOUSE_MODE_CAPTURED
)
if is_camera_motion:
_camera_input_direction = event.screen_relative * mouse_sensitivity
func _physics_process(delta: float) -> void:
_camera_pivot.rotation.x += _camera_input_direction.y * delta
_camera_pivot.rotation.x = clamp(_camera_pivot.rotation.x, tilt_lower_limit, tilt_upper_limit)
_camera_pivot.rotation.y -= _camera_input_direction.x * delta
_camera_input_direction = Vector2.ZERO
var raw_input := Input.get_vector("move_left", "move_right", "move_up", "move_down")
var forward := _camera.global_basis.z
var right := _camera.global_basis.x
var move_direction := forward * raw_input.y + right * raw_input.x
move_direction.y = 0.0
move_direction = move_direction.normalized()
var y_velocity := velocity.y
velocity.y = 0.0
velocity = velocity.move_toward(move_direction * move_speed, acceleration * delta)
velocity.y = y_velocity + _gravity * delta
var is_starting_jump := Input.is_action_just_pressed("jump") and is_on_floor()
if is_starting_jump:
velocity.y += jump_impulse
move_and_slide()
if move_direction.length() > 0.2:
_last_movement_direction = move_direction
var target_angle := Vector3.BACK.signed_angle_to(_last_movement_direction, Vector3.UP)
_skin.global_rotation.y = lerp_angle(_skin.rotation.y, target_angle, rotation_speed * delta)
if is_starting_jump:
_skin.jump()
elif not is_on_floor() and velocity.y < 0:
_skin.fall()
elif is_on_floor():
var ground_speed := velocity.length()
if ground_speed > 0.0:
_skin.move()
else:
_skin.idle()
You can use either node as the base for your character controllers, and each has its strengths.
The node gives you direct control over movement. We often use this for a more arcade-style or traditional movement feel. CharacterBody3D
The node simulates more realistic physics, which can make characters feel floaty or harder to control precisely. With this node, instead of setting velocity directly, you apply forces to the body. RigidBody3D
The upside is that it handles collisions and physics interactions automatically. It'll push other rigid bodies around, which is great when you want characters to interact with the environment in a more physical way.
The downside is that it doesn't come with all the precision and built-in features of the node. CharacterBody3D
You'll want to try both to see which fits your project better. I know development teams that mainly use rigid body physics for their characters, while at GDQuest we tend to prefer kinematic bodies like when they match the project's needs. CharacterBody3D
Yes, absolutely! The movement logic is nearly identical between first-person and third-person views. In a first-person view, you just won't need to separate the camera pivot from the camera itself.
This project comes with everything you need to focus on learning about coding a character controller:
lesson_reference/
folder, you'll find code checkpoints for different chapters of the videoImport the project in Godot 4.3+ to get started.
The character slides a bit when you release the movement keys. To make it stop more abruptly, you can add a stopping speed. When the character's velocity drops below this threshold and the player is not pressing any movement keys, set the velocity to zero:
@export var stopping_speed := 1.0
func _physics_process(delta: float) -> void:
# ...
if is_equal_approx(move_direction.length(), 0.0) and velocity.length() < stopping_speed:
velocity = Vector3.ZERO
# ...
This gives you snappier movement, which works well in fast-paced games where you need to quickly change direction or stop on a dime.
GDTours is a unique edtech that guides you right inside the Godot editor and drastically cuts down your learning time.
Check out this quick video on how it works!
Don't stop here. Step-by-step tutorials are fun but they only take you so far.
Try one of our proven study programs to become an independent Gamedev truly capable of realizing the games you’ve always wanted to make.
Get help from peers and pros on GDQuest's Discord server!
20,000 membersJoin ServerThere are multiple ways you can join our effort to create free and open source gamedev resources that are accessible to everyone!
Sponsor this library by learning gamedev with us onGDSchool
Learn MoreImprove and build on assets or suggest edits onGithub
Contributeshare this page and talk about GDQUest onRedditYoutubeTwitter…