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
-
Create a CharacterBody3D node.
-
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:
-
Input handling
-
Physics movement
-
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: boolDetermines whether the player can control the character.
@export var camera: Camera3D@export var collision_shape: CollisionShape3DOptional references. If left empty, debug versions will be spawned.
Movement Settings
@export var terrestrial: boolEnables gravity and ground physics.
@export var gravity: floatUses the engine’s default gravity from project settings.
@export var terminal_velocity: floatMaximum downward speed. Prevents acceleration to unrealistic values.
@export var move_speed: float@export var acceleration: float@export var deceleration: floatThese control how quickly the player starts and stops moving.
Jumping
@export var jump_strength: floatUpward 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_vThese 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_vSelf 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_hVertical rotation:
camera.rotation.x -= event.relative.y * mouse_sensitivity_vVertical 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_strengthThis 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:
-
terrestrialis true -
the player is not on the floor
velocity.y -= gravity * deltaVelocity 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(-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_CAPTUREDThe mouse is locked to the screen.
When inactive:
Input.MOUSE_MODE_VISIBLEThe 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.
com
full time NPC, part time ByteBloomer software developer



