# 3D character controllers One camera-relative movement core for FPS, third-person, and platformer, plus the floor contract and rigs. # 3D character controllers for FPS, third-person, and platformer games How to build a `CharacterBody3D` controller the Godot way, driven from swallowtail: one movement core shared by all three genres, then the rig and feel that make each distinct. Verified live. Jump-*feel* timers (coyote, buffer, double, cutoff) are the 2D ones from `game-patterns.md`/`platformer-2d.md` with the vertical sign flipped. This doc reuses them rather than re-deriving them. Read `spatial` discipline in `SKILL.md` before placing anything: seat the body with a raycast, verify numerically, don't eyeball one frame. ## Input actions first (never raw keys) Define the move/jump/interact actions before any movement code, so remapping and gamepads work and playtesting can drive `input action` (SKILL's rule: actions, not raw keys). One call per action (`move_back`/`_left`/`_right`, `jump`→`KEY_SPACE`, `interact`→`KEY_E` likewise): ``` input_map set-action --action move_forward --events '[{"type":"key","keycode":"KEY_W"}]' ``` `Input.get_vector("move_left","move_right","move_forward","move_back")` returns the normalized planar intent; `move_back` maps to `+y`, which becomes `+Z` (backward) below. `input_map` writes `project.godot`, so revert throwaway actions after tests. **Name the actions in `@export`s and check they exist.** A controller that reads an action nobody mapped does not error: `Input.is_action_pressed("sprint")` on a missing action just returns `false` forever, so the character silently never sprints and nothing anywhere says why. Take the action names as exported strings and validate them in `_ready`, disabling the feature rather than leaving it mysteriously dead: ```gdscript @export var can_sprint := true @export var input_sprint := "sprint" func _ready() -> void: if can_sprint and not InputMap.has_action(input_sprint): push_warning("No '%s' action in the InputMap, sprint disabled." % input_sprint) can_sprint = false ``` Exported action names also make the controller reusable across projects whose maps differ, and the warning shows up in `editor errors` / `runtime errors` instead of costing a debugging session. (Brackeys' CC0 proto controller uses this pattern; copy it into any generated controller.) ## The body: CharacterBody3D + a capsule The scene root *is* the body; add the collider under it (a `CharacterBody3D` root plus a child `CollisionShape3D`, rather than a nested second body): ``` scene create --path res://actors/player.tscn --root-type CharacterBody3D --root-name Player scene open --path res://actors/player.tscn node add --type CollisionShape3D --name Col --parent-path . node add-resource --node-path Col --property shape --resource-type CapsuleShape3D ``` The default `CapsuleShape3D` is `radius 0.5`, `height 2.0`, which is humanoid scale; resize by editing the shape resource. `scene3d add-body --type CharacterBody3D --shape capsule --radius 0.4 --height 1.8` is the one-call form for dropping a *standalone* body into a level (it nests body + sized shape under a parent), not for a root that is already the body. **Why a capsule, not a box.** The rounded bottom slides over small ledges, floor seams, and step edges that a box catches on, and its single curved side never snags on a wall corner mid-slide. It is the standard collider for anything that walks. `height` is the *full* height including both caps; the shape centers on the body origin, so raising `Col.position` to `y = height/2` puts the body origin at the feet, convenient for `spatial place_on`. **Two properties set the body's contract** (both verified on `CharacterBody3D`): - `motion_mode` selects the floor model. `MOTION_MODE_GROUNDED` (`0`, default) gives you `is_on_floor()`, slopes, snapping, and stairs behavior, which is what anything that walks needs. `MOTION_MODE_FLOATING` (`1`) drops all floor logic, for swimmers, flyers, and zero-g. - `up_direction` defaults to `Vector3.UP`, the axis `is_on_floor()` and the floor angle measure against. Change it only for wall-walking or planet gravity. ## The movement core (shared by all three genres) The canonical `_physics_process`. Read gravity from project settings (never hard-code `9.8`), build a **camera-relative** direction, accelerate with `move_toward`, then `move_and_slide()`: ```gdscript extends CharacterBody3D @export var speed := 5.0 @export var accel := 60.0 @export var friction := 40.0 @export var jump_velocity := 5.0 @onready var _cam_yaw: Node3D = $CameraPivot # FPS: use the body itself instead var _gravity: float = ProjectSettings.get_setting("physics/3d/default_gravity") func _physics_process(delta: float) -> void: if not is_on_floor(): velocity.y -= _gravity * delta # +Y is up in 3D: gravity is negative if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_velocity # positive = up var input := Input.get_vector("move_left", "move_right", "move_forward", "move_back") var dir := _cam_yaw.global_transform.basis * Vector3(input.x, 0.0, input.y) dir.y = 0.0 dir = dir.normalized() var target := dir * speed var rate := accel if dir != Vector3.ZERO else friction velocity.x = move_toward(velocity.x, target.x, rate * delta) velocity.z = move_toward(velocity.z, target.z, rate * delta) move_and_slide() ``` **The classic bug: forward is world `-Z`, not where the camera looks.** Feeding `Vector3(input.x, 0, input.y)` straight into `velocity` makes "forward" always drive world `-Z` no matter which way the player faces. Multiply the input by a **basis** to make it relative: - In an **FPS** the body yaws with the mouse, so use the body's own basis: `global_transform.basis * Vector3(input.x, 0, input.y)`. `dir.y = 0` before normalizing so looking up/down never changes ground speed. - In **third-person** the body does *not* yaw with the camera, so use the **camera pivot's** basis (`_cam_yaw` above). Movement follows the camera; the body turns to face it separately. Keep `speed`/`accel`/`friction` as `@export`s (inspector data over hard-coded values). Instant `velocity.x = dir.x * speed` feels robotic; `move_toward` gives weight, and lower `accel` with higher `friction` reads heavier. ## Floor behavior: slopes, snapping, stairs Four `CharacterBody3D` properties (all verified) shape how the body meets the ground; set them in `_ready()` or the inspector via `node set`: - `floor_max_angle` (radians, default `deg_to_rad(45)`) decides what counts as walkable. Anything steeper is a wall. `node set --node-path . --property floor_max_angle --value 0.8`. - `floor_snap_length` (default `0.1`) sets how far below the feet `move_and_slide` looks for floor to stick to. **Raise it (e.g. `0.5`) to walk *down* slopes and stairs without launching off the crest**; too small and the body ski-jumps every downhill. Snapping is skipped the frame you jump. - `floor_stop_on_slope` (default `true`) holds position on a slope with no input; turn it off for ice. - `floor_constant_speed` (default `false`) keeps ground speed constant up/down slopes, since the slope otherwise steals horizontal speed; turn it on for a snappy platformer. **Stairs, honestly.** Vanilla `move_and_slide()` does **not** step up stairs. The capsule rolls over lips smaller than its radius and a generous `floor_snap_length` keeps you glued going down, but a real step taller than the capsule's curve stops the body dead. Two vanilla-only fixes: model stairs as a ramp `StaticBody3D` under the visual steps (cheapest, most reliable), or add a step-up pass built from a shin-height forward `RayCast3D` that, on a hit with walkable floor just above, nudges `global_position.y` up by the step height before `move_and_slide`. Don't claim built-in stair-stepping the engine doesn't have. ## Moving platforms: velocity inheritance for free `AnimatableBody3D` (the 3D twin of `platformer-2d.md`'s `AnimatableBody2D`: a kinematic body driven by animation, `sync_to_physics = true`) carries riders correctly. `move_and_slide()` reads the platform's motion and adds it to the body automatically, no code on the character. Two `CharacterBody3D` knobs govern the hand-off: - `platform_on_leave` decides what happens to inherited velocity when you step off: `ADD_VELOCITY` (default; a moving floor flings you naturally), `ADD_UPWARD_VELOCITY`, or `DO_NOTHING`. - `platform_floor_layers` lists which collision layers count as velocity-inheriting floor. **Build:** author the platform as its own scene, animate its `position` on an `AnimationPlayer` (or a `PathFollow3D` + `RemoteTransform3D`, mirroring the 2D recipe), and instance it into the level. Reading `velocity` off the Player while it rides confirms the inheritance is live. ## FPS rig: head, camera, mouse-look, interaction ray Tree: a `Head` `Node3D` at eye height owns pitch; the `Camera3D` and interaction `RayCast3D` hang under it. The **body** yaws (whole capsule turns) and the **head** pitches. Never pitch the body (see Common mistakes). ``` node add --type Node3D --name Head --parent-path . node set --node-path Head --property position --value "Vector3(0,1.6,0)" node add --type Camera3D --name Camera3D --parent-path Head node add --type RayCast3D --name InteractRay --parent-path Head/Camera3D node set --node-path Head/Camera3D/InteractRay --properties '{"target_position":"Vector3(0,0,-3)","enabled":true}' ``` ```gdscript @export var sensitivity := 0.003 # exported: a settings menu writes it @onready var _head: Node3D = $Head @onready var _ray: RayCast3D = $Head/Camera3D/InteractRay func _ready() -> void: Input.mouse_mode = Input.MOUSE_MODE_CAPTURED # hide + lock the cursor to the window func _unhandled_input(event: InputEvent) -> void: if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var mm := event as InputEventMouseMotion rotate_y(-mm.relative.x * sensitivity) # yaw the body _head.rotate_x(-mm.relative.y * sensitivity) # pitch the head _head.rotation.x = clampf(_head.rotation.x, deg_to_rad(-89.0), deg_to_rad(89.0)) if event.is_action_pressed("ui_cancel"): Input.mouse_mode = Input.MOUSE_MODE_VISIBLE # release for menus func interact() -> void: # called on the "interact" action if _ray.is_colliding(): var hit := _ray.get_collider() if hit and hit.has_method(&"use"): hit.call(&"use") ``` `InputEventMouseMotion.relative` is the per-frame delta. Clamp pitch to just under ±90° so the view never flips. Expose `sensitivity` and an invert-Y toggle as `@export`s bound from a settings screen (`menus-settings.md`). The `RayCast3D` casts along its local `-Z` toward `target_position`; `get_collider()` returns the hit node, and a duck-typed `use()` keeps the door/lever/pickup decoupled from the player. ## Third-person rig: pivot, SpringArm3D, face-the-movement Tree: a `CameraPivot` `Node3D` at the shoulder (this is the `_cam_yaw` the movement core reads), a `SpringArm3D` under it, the `Camera3D` under the arm, and a separate `Skin` `Node3D` for the visible mesh so the body collider never rotates with the model. ``` node add --type Node3D --name CameraPivot --parent-path . node set --node-path CameraPivot --property position --value "Vector3(0,1.5,0)" node add --type SpringArm3D --name SpringArm --parent-path CameraPivot node add --type Camera3D --name Camera3D --parent-path CameraPivot/SpringArm node add --type Node3D --name Skin --parent-path . ``` `game-patterns.md` owns the **SpringArm3D** mechanics. It raycasts back from the pivot and shortens when geometry intrudes so the camera never clips walls; set `spring_length` (rest distance), `margin` (pad the hit), and `add_excluded_object` (a collider **RID**) so the arm ignores the player. Don't re-teach it; wire it: ``` node set --node-path CameraPivot/SpringArm --properties '{"spring_length":4.0,"margin":0.2}' ``` Exclude the player in `_ready()` with `_spring.add_excluded_object(get_rid())`, passing the body's own physics RID from `CollisionObject3D.get_rid()`. Dropping the player's layer from `SpringArm3D.collision_mask` works too. **Mouse orbits the pivot; the character turns to face where it moves** (not where the camera looks). Movement is camera-relative via the pivot basis (movement core, third-person branch); the skin lerps its yaw toward the velocity heading: ```gdscript @onready var _pivot: Node3D = $CameraPivot @onready var _skin: Node3D = $Skin @export var turn_speed := 12.0 func _unhandled_input(event: InputEvent) -> void: # orbit the pivot if event is InputEventMouseMotion: _pivot.rotate_y(-(event as InputEventMouseMotion).relative.x * 0.005) func _physics_process(delta: float) -> void: # ... movement core using _pivot.global_transform.basis, then move_and_slide() ... var planar := Vector3(velocity.x, 0.0, velocity.z) if planar.length() > 0.1: var target_yaw := atan2(-planar.x, -planar.z) # -Z is the model's forward _skin.rotation.y = lerp_angle(_skin.rotation.y, target_yaw, turn_speed * delta) ``` The `atan2(-x, -z)` maps a velocity heading to the yaw that points the model's `-Z` down it; `lerp_angle` eases the turn and wraps correctly across ±π. Alternative: `_skin.basis = _skin.basis.slerp(Basis.looking_at(planar, Vector3.UP), turn_speed * delta)` (both verified to compile). **Check which way the rig actually faces before trusting `-Z`.** Kenney's Blocky Characters and Mini Dungeon rigs face `+Z`, so the snippet above turned them to face the camera and put a forward-mounted hit `Area3D` behind the body (found 2026-09-17 in a sample build). For those, `atan2(planar.x, planar.z)` and a hitbox on local `+Z`. Read it off the model once with `spatial bounds` or a `runtime get` of a child's position after a known move rather than assuming. The same packs import every clip as one-shot (`loop_mode` 0), `idle` and `walk` included, so set the locomotion clips to `Animation.LOOP_LINEAR` in `_ready`. ## 3D platformer feel The forgiving-jump timers are identical to the 2D versions in `game-patterns.md` (coyote time, jump buffer) and its double-jump / variable-height blocks. **Only the sign flips**: 3D up is `+Y`, so the jump sets `velocity.y = +jump_velocity` and the cutoff fires while `velocity.y > 0.0` (rising), the mirror of 2D's negative-Y up. One `_physics_process`, all four layers: ```gdscript @export var jump_velocity := 5.0 @export var coyote_time := 0.1 @export var jump_buffer := 0.1 @export var max_air_jumps := 1 var _coyote := 0.0 var _buffered := 0.0 var _air_jumps := 0 func _physics_process(delta: float) -> void: if not is_on_floor(): velocity.y -= _gravity * delta _coyote = coyote_time if is_on_floor() else _coyote - delta # grace after leaving a ledge _buffered -= delta if Input.is_action_just_pressed("jump"): _buffered = jump_buffer # queue a slightly-early press if is_on_floor(): _air_jumps = max_air_jumps # refill on landing if _buffered > 0.0 and _coyote > 0.0: velocity.y = jump_velocity _buffered = 0.0; _coyote = 0.0 elif Input.is_action_just_pressed("jump") and _air_jumps > 0: velocity.y = jump_velocity * 0.85 # double jump: a touch weaker _air_jumps -= 1 if Input.is_action_just_released("jump") and velocity.y > 0.0: velocity.y *= 0.4 # variable height: cut the rise # ... horizontal move + move_and_slide() ... ``` Layering reads the same as 2D: cutoff shapes the arc, the midair jump rescues it, coyote makes the first jump reliable, the buffer keeps land→jump chains consistent. Fire a **landing squash** on the touchdown edge (`was_airborne and is_on_floor()`) with a `pop(1.2, 0.8)` tween on the `Skin`, per the juice grammar in `game-patterns.md` / `ui-polish-2d.md`. That is juice, not control, so keep it out of the physics. ## Verify by driving (the payoff) Prove the controller works by reading state back, not by one screenshot (SKILL's playtest loop). Arm any signal *before* awaiting it: ``` scene play --mode main runtime tree # confirm the rig is live runtime get --node-path Player --properties '["position","velocity"]' # baseline input action --action move_forward --pressed true runtime capture-frames --count 10 --frame-interval 2 # let it move runtime get --node-path Player --properties '["position","velocity"]' # position advanced, velocity.z < 0? input action --action move_forward --pressed false input action --action jump --pressed true runtime await-signal --node-path Player --signal landed --timeout 3 # if you emit one on touchdown runtime get --node-path Player --properties '["velocity"]' # velocity.y crossed +→−? runtime eval --code 'emit(get_tree().current_scene.get_node("Player").is_on_floor())' runtime screenshot --save-path user://char3d.png # visual sanity (works headless) scene stop ``` Input is fire-and-forget (`sent:true` ≠ applied), so confirm every claim ("it moved", "it jumped", "it's grounded") with a `runtime get`/`eval` read, never with the input's own success. To prove the movement is camera-relative, yaw the camera, then check that `move_forward` changes the world-space heading of `velocity`. ## Common mistakes - **World-basis forward**. Raw input into `velocity` ignores facing, so multiply by the body basis (FPS) or camera-pivot basis (third-person). The most common 3D-controller bug. - **Pitching the whole body**. Pitch the *head* `Node3D` and yaw the body. `rotate_x` on the `CharacterBody3D` tilts the collider and gimbals the character. - **Gravity in `_process`**. All movement goes in `_physics_process` with `delta`; `_process` is frame-rate-dependent and desyncs from collision. - **Mouse not captured**. Without `MOUSE_MODE_CAPTURED` the cursor leaves the window and mouse-look dies at the edge; restore `MOUSE_MODE_VISIBLE` for menus. - **SpringArm colliding with the player**. The arm raycasts into the player's own capsule and slams the camera to the face. Fix it with `add_excluded_object(get_rid())` (the body's RID), or drop its layer. - **`floor_snap_length` too small**. The body launches off every downhill and stair crest; raise it until walking down is glued. - **Hard-coded `9.8`** instead of reading `physics/3d/default_gravity`. - **Remembered signatures**. Confirm properties/methods against the running engine (`engine class-info`/`search`) before writing, because CharacterBody3D's floor API has evolved across engine versions.