Godot First Person Controller (Copy-Paste Ready)
comMar 12, 20266 min read

Godot First Person Controller (Copy-Paste Ready)

The controller is designed so that you can attach it to a node and immediately move around the scene, while still allowing full customization through exported variables.

Script

extends CharacterBody3D


#region declarations
@export var active: bool = true

@export var camera: Camera3D
@export var collision_shape: CollisionShape3D

@export var terrestrial: bool = true
@export var gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity")
@export var terminal_velocity: float = -1111.0
@export var move_speed: float = 4.0
@export var acceleration: float = 0.1
@export var deceleration: float = 0.1
@export var jump_strength: float = 6.0

@export var min_camera_rotation_degrees_v: float = -80.0
@export var max_camera_rotation_degrees_v: float = 80.0

@onready var min_camera_rotation_radians_v: float = deg_to_rad(min_camera_rotation_degrees_v)
@onready var max_camera_rotation_radians_v: float = deg_to_rad(max_camera_rotation_degrees_v)

var input_keys: Dictionary = {
	"forward": "W",
	"back": "S",
	"left": "A",
	"right": "D",

	"jump": "SPACE"
}

# input device config
@export var mouse_sensitivity_h: float = 0.01
@export var mouse_sensitivity_v: float = 0.01
#endregion


#region overrides
func _ready() -> void:
	setup_input_map()

	if not camera:
		camera = spawn_debug_camera()
	
	if not collision_shape:
		collision_shape = spawn_debug_collision_shape()

	set_active(active)


func _unhandled_input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_cancel"):
		set_active(not active)
	
	if not active:
		return
	
	if event is InputEventMouseMotion:
		rotation.y -= event.relative.x * mouse_sensitivity_h
		rotation.y = wrapf(rotation.y, 0.0, TAU)
		camera.rotation.x -= event.relative.y * mouse_sensitivity_v
		camera.rotation.x = clampf(
			camera.rotation.x,
			min_camera_rotation_radians_v,
			max_camera_rotation_radians_v
		)

	if event.is_action_pressed("jump") and terrestrial and is_on_floor():
		velocity.y = jump_strength


func _physics_process(delta: float) -> void:
	if terrestrial and not is_on_floor() and velocity.y > terminal_velocity:
		velocity.y -= gravity * delta

	var move_direction: Vector3 = horizontal_input_direction(
		global_basis,
		get_input_vector("forward", "back", "left", "right")
	)
	if move_direction == Vector3.ZERO:
		velocity = lerp(velocity, Vector3(0.0, velocity.y, 0.0), deceleration)
	else:
		var final_move_speed: float = move_speed
		var final_direction: Vector3 = (move_direction * final_move_speed) + Vector3(0.0, velocity.y, 0.0)
		velocity = lerp(velocity, final_direction, acceleration)

	move_and_slide()
#endregion


#region input
func set_active(state: bool) -> void:
	active = state
	if active:
		camera.current = true
		Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
	else:
		Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)


static func get_input_vector(forward_input: String, back_input: String, left_input: String, right_input: String) -> Vector2:
	var input_direction: Vector2 = Input.get_vector(left_input, right_input, forward_input, back_input)
	return input_direction


static func horizontal_input_direction(view_basis: Basis, input_direction: Vector2) -> Vector3:
	var vector3_input_direction := Vector3(input_direction.x, 0, input_direction.y)
	var result: Vector3 = (view_basis * vector3_input_direction).normalized()
	return result
#endregion


#region debug nodes
func spawn_debug_camera(height: float = 1.5) -> Camera3D:
	var transform_node := Node3D.new()
	add_child(transform_node)
	
	var new_camera := Camera3D.new()
	transform_node.add_child(new_camera)
	new_camera.position.y = height
	return new_camera


func spawn_debug_collision_shape(height: float = 1.75, radius: float = 0.15) -> CollisionShape3D:
	var collision_node := CollisionShape3D.new()
	collision_node.shape = CapsuleShape3D.new()
	collision_node.shape.height = height
	collision_node.shape.radius = radius
	add_child(collision_node)
	collision_node.position.y = height / 2.0
	return collision_node
#endregion


#region input_map
func setup_input_map() -> void:
	for action_name: String in input_keys.keys():
		setup_input(action_name, input_keys[action_name])


static func setup_input(action_name: String, default_key: String) -> void:
	if not InputMap.has_action(action_name):
		InputMap.add_action(action_name)
	
	if InputMap.action_get_events(action_name).is_empty():
		var event := InputEventKey.new()
		event.keycode = OS.find_keycode_from_string(default_key)
		InputMap.action_add_event(action_name, event)
#endregion

Breakdown

This script implements a simple, reusable First Person Controller (FPC) for Godot 4 using CharacterBody3D. It provides:

  • Mouse look (horizontal + vertical)

  • WASD movement

  • Jumping

  • Gravity and terminal velocity

  • Configurable acceleration and deceleration

  • Toggleable player control

  • Automatic fallback camera and collision shape if none are assigned

The controller is designed so that you can attach it to a node and immediately move around the scene, while still allowing full customization through exported variables.


How to Use

  1. Create a CharacterBody3D node.

  2. Attach this script to it.

If no camera or collision shape exists, the script spawns debug versions automatically, so the controller still works.


Core Design

The script relies on three main systems:

  1. Input handling

  2. Physics movement

  3. Camera control

Godot’s CharacterBody3D already provides collision resolution and movement helpers such as move_and_slide(), so the controller mainly focuses on generating the correct velocity.


Exported Variables

These values appear in the Inspector, allowing designers to tweak the controller without editing code.

Core Settings

@export var active: bool

Determines whether the player can control the character.

@export var camera: Camera3D
@export var collision_shape: CollisionShape3D

Optional references. If left empty, debug versions will be spawned.


Movement Settings

@export var terrestrial: bool

Enables gravity and ground physics.

@export var gravity: float

Uses the engine’s default gravity from project settings.

@export var terminal_velocity: float

Maximum downward speed. Prevents acceleration to unrealistic values.

@export var move_speed: float
@export var acceleration: float
@export var deceleration: float

These control how quickly the player starts and stops moving.


Jumping

@export var jump_strength: float

Upward velocity applied when jumping.

Jumping only works when is_on_floor() is true.


Camera Limits

@export var min_camera_rotation_degrees_v
@export var max_camera_rotation_degrees_v

These prevent the player from rotating the camera past realistic angles (for example, flipping upside down).

They are converted to radians @onready because Godot’s rotation system uses radians internally.


Mouse Sensitivity

@export var mouse_sensitivity_h
@export var mouse_sensitivity_v

Self explanatory.

Separate horizontal and vertical sensitivity values allow fine tuning.


Initialization (_ready)

When the node enters the scene tree:

func _ready():

The script checks whether required components exist.

If they do not, it generates debug replacements:

  • spawn_debug_camera()

  • spawn_debug_collision_shape()

This allows the controller to function even in a blank scene.

Finally:

set_active(active)

This determines whether the mouse is captured.


Input Handling

Input is processed in:

func _unhandled_input(event):

This method receives input not consumed by UI elements.


Toggle Player Control

Pressing ui_cancel toggles control:

set_active(not active)

When active:

  • Mouse is captured

  • Camera moves with mouse

When inactive:

  • Mouse is visible

  • Player movement stops


Mouse Look

Mouse motion rotates the player and camera:

Horizontal rotation:

rotation.y -= event.relative.x * mouse_sensitivity_h

Vertical rotation:

camera.rotation.x -= event.relative.y * mouse_sensitivity_v

Vertical rotation is clamped to prevent over-rotation:

camera.rotation.x = clampf( camera.rotation.x, min_camera_rotation_radians_v, max_camera_rotation_radians_v)

This creates the typical FPS camera behaviour.


Jump Input

Jumping occurs when:

event.is_action_pressed("jump") and terrestrial and is_on_floor():

If all conditions are true:

velocity.y = jump_strength

This adds upward velocity to the character.

Note: if not terrestrial you might want to implement a different way of performing vertical motion. That's opinionated and not basic so it's beyond the scope of this script.


Movement Physics

Movement is handled inside:

func _physics_process(delta):

This runs at the engine’s physics tick.

Reference:
https://docs.godotengine.org/en/stable/tutorials/physics/physics_introduction.html


Gravity

Gravity applies only when:

  • terrestrial is true

  • the player is not on the floor

velocity.y -= gravity * delta

Velocity is limited by terminal_velocity.


Movement Direction

Input from WASD is collected using: Input.get_vector() inside get_input_vector()

This returns a normalized 2D direction vector.

Example:

Vector2(0,1) → forward
Vector2(-1,0) → left

Converting to World Movement

The function:

horizontal_input_direction(view_basis, input_direction)

converts the 2D input into a 3D movement vector relative to the player's rotation.

This ensures pressing W always moves forward relative to where the player is facing.


Acceleration and Deceleration

Instead of instantly changing velocity, the script uses interpolation:

velocity = lerp(velocity, final_direction, acceleration)

For:

  • smoother movement

  • more natural stopping behaviour

If there is no input, the character slows down using deceleration.


Final Movement

Movement is executed using:

move_and_slide()

This is the standard movement method for CharacterBody3D.

It automatically handles:

  • floor detection

  • slopes

  • collision response


Helper Functions

set_active()

Handles enabling or disabling player control.

When active:

Input.MOUSE_MODE_CAPTURED

The mouse is locked to the screen.

When inactive:

Input.MOUSE_MODE_VISIBLE

The cursor becomes visible.


Input Vector Helper

get_input_vector()

A wrapper around Input.get_vector().

It converts four directional inputs into a single normalized vector.


Horizontal Movement Conversion

horizontal_input_direction()

Transforms the input vector into world space using the player's orientation.

This ensures movement follows the direction the player is looking.


Automatic Debug Components

To reduce setup time, the script can create default components.


Debug Camera

spawn_debug_camera()

Creates a camera attached to a pivot node and places it at head height.


Debug Collision Shape

spawn_debug_collision_shape()

Creates a CapsuleShape3D, which is the standard collider used in FPS controllers.

This prevents the player from getting stuck on edges and slopes.

C

com

full time NPC, part time ByteBloomer software developer

Comments