Create a 3D Character Controller in Godot 4

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.

Pre-requisites

This tutorial assumes that you have:

  • Godot foundations
  • Programming foundations
  • Knowledge of Godot 3D basics

If you're just getting started, here are some resources to help you learn the basics of Godot and GDScript you'll need:

Code checkpoints

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.

Coding the camera

Code Reference: res://player_3d.gd

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

Coding the ground movement

Code Reference: res://player_3d.gd

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

Animating the character skin

Code Reference: res://player_3d.gd

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

Adding jump and fall

Code Reference: res://player_3d.gd

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

Questions and troubleshooting

Why use the CharacterBody3D node instead of the RigidBody3D node?

You can use either node as the base for your character controllers, and each has its strengths.

The CharacterBody3D node gives you direct control over movement. We often use this for a more arcade-style or traditional movement feel.

The RigidBody3D 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.

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 CharacterBody3D node.

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 CharacterBody3D when they match the project's needs.

Can I use this controller for first-person games too?

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.

Download files

This project comes with everything you need to focus on learning about coding a character controller:

Import the project in Godot 4.3+ to get started.

Updates / Code patches

Bonus

Making the character stop more sharply so it feels more responsive

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.

FFWD to using Godot for 3D

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!

Nathan

Founder and teacher at GDQuest
Check out GDSchool
Become an Indie Gamedev with GDQuest!

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.

Nathan

Founder and teacher at GDQuest
  • Starter Kit
  • Learn Gamedev from Zero
Check out GDSchool

You're welcome in our little community

Get help from peers and pros on GDQuest's Discord server!

20,000 membersJoin Server

Contribute to GDQuest's Free Library

There are multiple ways you can join our effort to create free and open source gamedev resources that are accessible to everyone!