Blog

Godot 4 third-person controller with AnimationTree

A tested Godot 4 third-person controller: camera-relative movement, an AnimationTree for walk, run, jump and attacks, stairs, and a camera that won't clip.

A third-person controller looks like a weekend job: move, turn, jump, a camera on a stick. Then the camera goes through a wall, the walk stops after one step, the jump floats, the attack freezes the legs, and the character runs on the spot against a wall. We went through about 900 threads on the Godot forum, godotforums.org and Game Development Stack Exchange, plus the open issues in Godot's tracker, and sorted the problems by how many people looked at them. This guide builds a controller that solves each one, and explains why each fix works.

Everything here runs in Godot 4.7.2 with a character from Hatchery3D. An automated test walks the player through 25 checks, from "the walk keeps looping after four seconds" to "the camera stays on the player's side of the wall". The two scripts are complete at the end of the post. They work with any humanoid character that has idle, walk, run, jump, slash, punch, hit and death clips.

A character running away from a third-person camera in Godot, toward a set of stairs, with a wall on the right.

What you'll build

  • Movement relative to the camera, with acceleration, on keyboard and gamepad.
  • A character that turns at a steady speed to face where it goes.
  • A camera on a SpringArm3D that orbits with the mouse or the right stick, pulls in before walls, and doesn't jitter.
  • An AnimationTree built in code: idle, walk and run blended by real speed; jump, fall and land; attacks that keep the legs walking; a hit reaction; death.
  • A jump that leaves the ground the moment you press it, with coyote time, a jump buffer and a shorter hop when you tap.
  • Stairs, slopes and ledges.

If you haven't imported a character yet, start with how to import a GLB character with animations into Godot 4. That guide also shows how to read each clip's measured speed from the file, which this one uses.

The scene

Player (CharacterBody3D)              player.gd
├── CollisionShape3D                  CapsuleShape3D, height 1.8, radius 0.3, y = 0.9
├── character                         your .glb, dragged in and renamed
├── CharacterAnimator (AnimationTree)  character_animator.gd
└── CameraPivot (Node3D)
    └── SpringArm3D                   Spring Length 4, Shape: SphereShape3D r 0.25, Margin 0.1
        └── Camera3D

Four settings in the Inspector decide whether it works at all:

  1. CharacterAnimator → Anim Player: ../character/AnimationPlayer.
  2. CharacterAnimator → Root Node: ../character. The tree resolves every track path from this node. Left at its default (..), it looks for Player/Rig/Skeleton3D instead of character/Rig/Skeleton3D, finds nothing, and the character stands still in the game. This is behind most "my animation works in the editor but not in game" threads.
  3. Player → Collision → Layer 2 and Mask 1. Put the level on layer 1.
  4. SpringArm3D → Collision Mask: 1 only, so the arm ignores the player.

Turn on Physics → Common → Physics Interpolation in the Project Settings (switch on Advanced Settings to see it). The camera section explains why.

You don't need to set up the Input Map. The script adds move_forward, move_back, move_left, move_right, look_*, jump, sprint and attack with WASD, the arrow keys, Space, Shift, the left mouse button and a gamepad, unless you already have actions with those names. Your own bindings win.

How do I move relative to the camera?

This is the most-viewed controller question on the Godot forum. The default CharacterBody3D template moves along the world axes, so "forward" stays north however you turn the camera. Turn the input by the camera's yaw, and only the yaw:

func _move_direction() -> Vector3:
	var input := Input.get_vector(&"move_left", &"move_right", &"move_forward", &"move_back")
	# Forward is where the camera looks, flattened onto the floor.
	return Basis(Vector3.UP, _yaw) * Vector3(input.x, 0.0, input.y)

Use the yaw alone, not the camera's full basis. With the camera's basis, looking down tilts "forward" into the floor and the character slows down. Input.get_vector also keeps diagonals from being faster than straight lines, which the forum reports as "my player runs faster in some directions".

The player then eases its velocity toward the target with move_toward, so it speeds up and slows down at a fixed rate, with less control in the air:

var rate := acceleration if is_on_floor() else acceleration * air_control
var horizontal := Vector3(velocity.x, 0.0, velocity.z).move_toward(target, rate * delta)

How do I make the character face where it walks?

Godot's forward is −Z, but a character from a glTF file faces +Z. So look_at() turns its back to the target, and rotation.y = atan2(direction.x, direction.z) turns its face toward the direction.

To turn smoothly, don't snap the rotation and then lerp it. That's the usual mistake in the threads, and lerp_angle(a, a, t) from a value to itself does nothing. Also, a lerp with a fixed weight slows down as it gets close and never quite arrives. rotate_toward turns at a steady speed and stops exactly on the angle, the shorter way round:

if direction.length() > 0.1:
	var facing := atan2(direction.x, direction.z)
	model.rotation.y = rotate_toward(model.rotation.y, facing, turn_speed * delta)

Turn the character node, not the Player. The body's capsule is round, so it doesn't need to turn, and the camera rig stays independent.

The camera: orbit, walls and jitter

The pivot follows the player at head height and holds the yaw and pitch. The spring arm points backwards from it, and the camera sits at the end of the arm.

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		_yaw -= event.relative.x * mouse_sensitivity
		_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)


func _process(delta: float) -> void:
	var look := Input.get_vector(&"look_left", &"look_right", &"look_up", &"look_down")
	_yaw -= look.x * stick_sensitivity * delta
	_pitch = clampf(_pitch - look.y * stick_sensitivity * delta, min_pitch, max_pitch)
	pivot.global_position = get_global_transform_interpolated().origin + Vector3.UP * camera_height
	pivot.rotation = Vector3(_pitch, _yaw, 0.0)

The mouse and the stick need different code. A mouse sends an event for each move, so you add event.relative once per event. A stick held still sends no events at all, so read it every frame in _process and multiply by delta. That's why the gamepad threads say "the camera only moves while I wiggle the stick".

Esc frees the mouse, and a click takes it back. The click that recaptures the mouse is marked as handled, so it doesn't also swing the sword.

Why does my camera clip through walls?

The SpringArm3D issue on GitHub has been open since 2022, and the forum has a dozen versions of it. Three settings fix it:

  • Give the arm a shape. With no shape, the arm casts a thin ray. The ray stops at the wall, but the camera's near plane, a few centimetres in front of the lens, is already inside it. A SphereShape3D of 0.25 m keeps the whole lens out.
  • Exclude the player. If the arm can hit the player's own capsule, it collapses into the character's head. Keep the player off the arm's collision mask, and to be safe, call spring_arm.add_excluded_object(get_rid()) in _ready.
  • Keep the pivot inside the body. The arm casts from the pivot. If the pivot is outside the capsule, for example far to the side for an over-the-shoulder view, the cast can start inside the wall. Keep the pivot over the body's centre, and move the camera sideways with an offset on the camera, not on the pivot.

In the test, the arm is 4 m long in the open. With the player 1.25 m from a wall and the camera behind, it shortens to 1.06 m and the camera stays in front of the wall.

Why does my camera jitter?

Physics runs 60 times a second. Your screen may draw 120 or 144 times. A camera glued to a body that only moves 60 times a second shows the steps. Godot 4.4 and later fix this in three parts:

  1. Turn on physics interpolation in the Project Settings. Godot then draws bodies between their physics positions.
  2. Move the pivot to get_global_transform_interpolated(), the drawn position, in _process. Follow the plain global_position, and you follow the steps again.
  3. Turn interpolation off on the pivot, because you move it every drawn frame yourself:
pivot.top_level = true
pivot.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF

Without the third step, Godot warns about an "interpolated Camera3D triggered from outside physics process". top_level frees the pivot from the player's rotation, so turning the character doesn't swing the camera.

The AnimationTree, built in code

You can build the tree in the editor's graph. We build it in code: it's the same every time, it fits in a blog post, and you can still inspect it while the game runs (Remote tab, select the AnimationTree). This is its shape:

State machine
├── move           Blend tree
│   ├── locomotion     BlendSpace1D: idle at 0, walk at 1.3, run at 2.6 (m/s)
│   ├── upper_body     OneShot, filtered to Spine1 and every bone above it
│   ├── full_body      OneShot, no filter
│   └── hit            OneShot over everything
├── jump           take-off to the top of the jump
├── fall           the top of the jump, held
├── land           landing and recovery
└── death

The player's script never calls AnimationPlayer.play(). While a tree is active, it decides what plays, and play() calls fight it. The player only reports what happens: its speed, a jump, a landing, an attack.

Why does my AnimationTree play nothing?

The state machine starts in its Start node. A transition from Start with the default Advance Mode, Enabled, waits for you to travel. Nothing travels, so the tree plays no animation, and the character holds its rest pose. Set the first transition to Auto:

var start := _transition(0.0)
start.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
machine.add_transition(&"Start", &"move", start)

Why does my walk stop after one step?

Godot imports every clip to play once. Inside a tree, walk reaches its last frame after 2.3 s and stays there, so the character glides along with its legs frozen. The tree has no loop setting of its own for these clips: set the loop on the clips.

for clip in [&"idle", &"walk", &"run"]:
	player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR

You can also do it in the import settings, as the import guide shows. In code, it survives a new take of the file and works for characters you load at runtime. The test checks it: after four seconds of walking, the left foot still moves 0.35 m back and forth. Without the loop, it moves 0.000 m.

How do I blend idle, walk and run without sliding feet?

Put the blend points at the speeds the clips were made for, in metres per second, and feed the blend space the body's speed. For our character those are 1.318 m/s for the walk and 2.573 m/s for the run. A Hatchery3D file stores them on each clip as speed_mps. With other characters, measure them: walk the character in a straight line and change the speed until the feet stop sliding.

locomotion.add_blend_point(_clip(&"idle"), 0.0, -1, &"idle")
locomotion.add_blend_point(_clip(&"walk"), walk_speed, -1, &"walk")
locomotion.add_blend_point(_clip(&"run"), run_speed, -1, &"run")

This works between the points too. At half the walk speed, the blend is half idle and half walk: half a stride at the same rhythm, so the feet still roughly keep up with the floor.

Feed it the real speed, not the speed the player asked for:

var real := get_real_velocity()
animator.set_speed(Vector2(real.x, real.z).length())

get_real_velocity() is how far the body actually moved. Against a wall it's zero, so the character stands in idle instead of running on the spot. On a slope it's the horizontal part of the speed, so the legs slow a little uphill, as real legs do.

Godot 4.7 warns when a blend point has no name, so give each one a name, as above.

Why does my jump lag, float, or play only the first frame?

Three different problems hide in this question.

Only the first frame. The code calls play("jump") or travel("jump") on every physics frame while the character is in the air. Each call starts the clip again. Tell the tree once, when the jump starts, and let the state machine move on by itself.

It floats. Many jump clips, ours included, are a whole standing jump: wind-up, take-off, the flight with the height of the jump in the hips, the landing and the recovery. Play that while physics lifts the body, and the character goes up twice: once from the velocity, once from the clip. Here's our clip, measured:

TimeWhat happensHips
0.0–0.4 sStanding0.96 m
0.4–0.75 sWind-up crouchDown to 0.81 m
0.75 sFeet leave the floorRising
1.1 sTop of the jump1.41 m
1.4 sFeet touch downDown to 0.77 m
1.4–2.0 sRecoveryBack to 0.96 m
2.0–3.0 sStanding0.96 m

It lags. The feet leave the floor 0.75 s after the clip starts. Wait for that before you jump, and the game feels slow. Don't wait, and the character flies while it still crouches.

The animator cuts the clip into three parts, and takes the height out of the hips:

var parts := {
	&"jump_up": [0.70, 1.10, Animation.LOOP_NONE],
	&"jump_fall": [1.05, 1.20, Animation.LOOP_PINGPONG],
	&"jump_land": [1.35, 1.95, Animation.LOOP_NONE],
}

_flatten_hips caps the hips at their standing height. The crouch of the landing stays, because it's below standing height, but the rise is gone: the body's velocity does the rising. The jump starts at 0.70 s, at the push-off, so the velocity and the pose start together on the same frame. The test checks that the state is jump and the body is rising 4.4 m/s two frames after the press.

These times are for a Hatchery3D jump. For another clip, scrub it in the AnimationPlayer and note when the feet leave and touch the floor.

Four frames of the jump from the side: the push-off, rising with the knees pulled up, the top of the jump, and coming down.

If you want the wind-up in your game, play the clip from 0.45 s instead and add the velocity 0.3 s after the press. It feels heavier, which suits some characters. Decide on purpose.

Jump, fall, land

The state machine does the rest:

  • move → jump when the player jumps. jump → fall at the end of the take-off, automatically (Switch Mode At End, Advance Mode Auto).
  • move → fall when the player walks off a ledge, after the coyote time.
  • jump or fall → land when the body touches the floor.
  • land → move when the landed condition is true. The player sets it after half a second standing still, or after 0.1 s if the player is already moving again, so a run doesn't stall on every landing.

A state machine can't have two transitions between the same two states. We first tried one immediate land → move and one at the end of the clip. Godot kept only one, and the character stayed in land. One transition with a condition does both jobs.

Coyote time, jump buffer and a short hop

Three small timers make a jump feel fair:

func _update_jump(delta: float) -> void:
	_since_floor = 0.0 if is_on_floor() else _since_floor + delta
	_since_jump_press += delta
	if Input.is_action_just_pressed(&"jump"):
		_since_jump_press = 0.0

	if _since_jump_press <= jump_buffer and _since_floor <= coyote_time and not _jumping:
		velocity.y = sqrt(2.0 * get_gravity().length() * jump_height)
		_jumping = true
		_since_jump_press = INF
		_since_floor = INF
		animator.jump()
	# Let go early for a lower jump.
	if Input.is_action_just_released(&"jump") and velocity.y > 0.0:
		velocity.y *= 0.5
  • Coyote time: the jump still works for 0.12 s after the feet leave a ledge. The test presses jump four frames after walking off a 1 m ledge, and the character jumps.
  • Jump buffer: a press up to 0.12 s before landing counts when the feet touch down.
  • Short hop: releasing jump while rising halves the upward speed. Held, the jump reaches 1.04 m for a jump_height of 1.0. Tapped, it reaches 0.43 m.

sqrt(2 * g * h) is the speed that reaches height h under gravity g. Use it, and jump_height is in metres instead of a magic number.

How do I attack while running?

With one play("slash"), the slash replaces the walk, and the character slides along on frozen legs. That's the "prevent my player from moving while attacking" thread, from the other side. Play the attack on the upper body only, with a filtered OneShot:

var upper := AnimationNodeOneShot.new()
upper.filter_enabled = true
for path in _upper_body_paths():
	upper.set_filter_path(path, true)

_upper_body_paths() lists Spine1 and every bone above it, as track paths like Rig/Skeleton3D:Chest. Only those bones take the slash. The hips and legs keep the walk underneath.

Four frames of a slash while walking: the arm winds up and swings across while the legs keep their stride.

Standing still, the whole body should swing, so the animator has a second OneShot with no filter, and the player picks one by speed. During a full-body attack, the player's target speed is zero, so the feet stay planted:

if animator.get("parameters/move/full_body/active"):
	target = Vector3.ZERO

Two OneShot details from the threads:

  • Don't wait for animation_finished. It doesn't fire reliably for clips inside a tree (there's an open issue). Read the OneShot's active parameter instead, as is_attacking() does.
  • Don't fire a OneShot that is already playing. A second request restarts it, and the first attack comes back after the second. attack() returns early while either OneShot is active.

A Transition node in front of each OneShot picks the clip, so the same OneShot plays slash or punch.

Hits and death

The hit reaction is a third OneShot on top of everything, so a hit can interrupt an attack. Death is a state of its own with transitions from every other state. A clip that plays once stays on its last frame, so the character stays down. The player stops reading input when it dies.

How do I walk up stairs with CharacterBody3D?

CharacterBody3D slides along a step instead of climbing it. The usual answer is an invisible ramp over each staircase. That works, and it's still the cleanest fix for long flights of stairs. For steps scattered through a level, a step-up check before move_and_slide handles them all:

func _step_up(delta: float) -> void:
	if not is_on_floor():
		return
	var motion := Vector3(velocity.x, 0.0, velocity.z) * delta
	if motion.length() < 0.001:
		return
	var hit := KinematicCollision3D.new()
	if not test_move(global_transform, motion, hit):
		return
	if hit.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
		return # A slope: move_and_slide walks up it.
	var up := Vector3.UP * step_height
	if test_move(global_transform, up):
		return # No room above.
	var raised := global_transform.translated(up)
	if test_move(raised, motion):
		return # Too high: still blocked one step up.
	# Look for the top of the step one capsule radius ahead: the round bottom
	# of the capsule meets the edge of the step at a steep angle.
	var probe := motion.normalized() * (motion.length() + _radius())
	if test_move(raised, probe):
		return
	var down := KinematicCollision3D.new()
	if test_move(raised.translated(probe), -up, down):
		var rise := step_height - down.get_travel().length()
		if rise > 0.01 and down.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
			global_position.y += rise
			# The body pops up in one frame. Keep the model where it was and
			# let it catch up, so the step looks like a step.
			model.position.y -= rise

The probe is the detail that took us a test run to find. Our first version looked down for the step just one frame's movement ahead. The capsule's round bottom met the edge of the step there, at 59°. That's steeper than the 45° floor limit, so the code refused the step, and the character stood stuck at the first stair. Looking one capsule radius ahead finds the flat top of the step.

Raising the body by a whole step in one physics frame looks like a teleport. So the code lowers the model by the same amount, and _physics_process lifts it back at 1.5 m/s:

model.position.y = move_toward(model.position.y, 0.0, step_catch_up * delta)
Three frames from the side: the character walks up four 0.2 m steps onto a platform.

In the test, the player walks up four 0.2 m steps onto a 0.8 m platform, and the model has caught up with the body when it arrives.

Slopes

Three properties of CharacterBody3D handle slopes:

  • floor_snap_length keeps the body on the ground going down a slope or a step. Without it, the body leaves the ground on each step down and bounces, which is the "character bounces down slopes" thread. We set it to the step height.
  • floor_constant_speed keeps the speed the same uphill and downhill.
  • floor_max_angle, 45° by default, is the steepest slope the body can walk on.

Add gravity only in the air, as the script does. On the ground it only pushes the body into the floor every frame, and forum threads tie it to sticky, scraping movement along walls. floor_stop_on_slope, on by default, keeps the body from sliding down a slope while it stands still. The test stands on a 25° slope for a second, and the body doesn't move.

Troubleshooting

You seeThe causeThe fix
The tree plays nothing, the character holds its rest poseThe transition out of Start isn't AutoSet Advance Mode to Auto
It works in the editor but not in the gameThe tree's Root Node is wrong, or the tree isn't activeRoot Node ../character; set active = true
travel() does nothingA typo in the state name, no path to the state, or another travel() every framePrint playback.get_current_node(); call travel() only when something changes
The walk stops after one stepThe clips play onceSet loop_mode on idle, walk and run
The jump plays only its first frameplay() or travel() every frame restarts itTell the tree once, when the jump starts
The character goes up twice as highThe clip moves the hips up while physics moves the bodyCap the hips at standing height
The jump feels slowThe clip winds up before the feet leaveStart the clip at the push-off
animation_finished never firesThe clip plays inside the treeRead the OneShot's active parameter
An earlier attack plays again after a second oneA OneShot fired while it was still playingDon't fire while active
The legs freeze during an attackThe attack replaces the whole bodyA OneShot filtered to the upper body
It runs on the spot against a wallThe animation reads the input, not the movementUse get_real_velocity()
The character walks backwardsIt faces +Z, Godot's forward is −Zatan2(direction.x, direction.z)
It snaps instead of turningSnapped first, or a lerp from a value to itselfrotate_toward each frame
The camera goes through wallsThe arm has no shape, or it hits the playerA sphere shape; exclude the player
The camera jittersPhysics steps at 60 Hz, the screen draws fasterPhysics interpolation; follow the interpolated position
The gamepad camera moves only while the stick movesThe stick is read in _unhandled_inputRead it with Input.get_vector in _process
It bounces down slopesNo floor snapfloor_snap_length
It scrapes along wallsGravity added on the groundAdd gravity only in the air
It stops at the first stairCharacterBody3D doesn't climb stepsThe step-up check, probing one radius ahead
It falls through the floorThe CollisionShape3D isn't a direct child of the bodyPut it right under Player

How we tested it

A headless script builds a level with a wall, four steps, a 25° slope and a 1 m ledge, puts the player in it, and presses the actions with Input.action_press. It checks:

  • The character starts in idle on the floor.
  • It walks at 1.32 m/s away from the camera, and runs at 2.57 m/s.
  • Against the wall, the blend returns to idle.
  • The walk keeps looping after four seconds.
  • The jump starts on the second frame, reaches 1.04 m when held and 0.43 m when tapped, goes jump → fall → land, and returns to move.
  • An attack while walking plays on the upper body, and the body keeps moving at 1.32 m/s. A standing attack plays on the whole body, and the feet stay planted.
  • It climbs four 0.2 m steps to 0.8 m, and the model catches up with the body.
  • It walks up the 25° slope and stands on it without sliding.
  • Walking off the ledge goes to fall, then land. A jump four frames after leaving the ledge still works.
  • The camera arm is 4 m long in the open, shortens to 1.06 m near the wall, and stays on the player's side.
  • The hit reaction fires, and death holds.

The first runs caught three of the problems in this post: the tree waiting in Start, the two transitions between the same states, and the stair probe. One of our own checks was wrong too. The loop check first sampled the run too early, so it passed even with a frozen walk. Now it fails without the fix and passes with it.

The full scripts

character_animator.gd

Attach it to the AnimationTree. Set Walk Speed and Run Speed in the Inspector to your character's measured speeds.

class_name CharacterAnimator
extends AnimationTree
## Builds the whole animation graph from code: locomotion, jump, fall, land,
## attacks that keep the legs walking, a hit reaction and death.
## Put it under the player, point Anim Player at the character's
## AnimationPlayer and Root Node at the character.

## Measured speeds of the walk and run clips, in metres per second.
## A Hatchery3D character stores them as speed_mps in the file.
@export var walk_speed := 1.3
@export var run_speed := 2.6
## The first bone of the upper body. Attacks while moving play on this bone
## and everything below it, so the legs keep walking.
@export var upper_body_bone := &"Spine1"

const JUMP_CLIP := &"jump"

var _state: AnimationNodeStateMachinePlayback


func _ready() -> void:
	var player := get_node(anim_player) as AnimationPlayer
	# Godot imports every clip to play once. Loop the gaits, or the walk
	# stops on its last frame after one cycle.
	for clip in [&"idle", &"walk", &"run"]:
		player.get_animation(clip).loop_mode = Animation.LOOP_LINEAR
	_make_jump_clips(player)
	tree_root = _build_graph()
	active = true
	_state = get("parameters/playback")


## Horizontal speed in metres per second. Picks idle, walk, run or a blend.
func set_speed(speed: float) -> void:
	set("parameters/move/locomotion/blend_position", speed)


## One-shot attack. Upper body only while moving, full body while standing.
func attack(clip: StringName, moving: bool) -> void:
	if is_attacking():
		return
	set("parameters/move/attack_clip/transition_request", String(clip))
	set("parameters/move/upper_body_clip/transition_request", String(clip))
	var shot := "upper_body" if moving else "full_body"
	set("parameters/move/%s/request" % shot, AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)


func is_attacking() -> bool:
	return get("parameters/move/upper_body/active") or get("parameters/move/full_body/active")


func hit() -> void:
	set("parameters/move/hit/request", AnimationNodeOneShot.ONE_SHOT_REQUEST_FIRE)


func jump() -> void:
	set("parameters/conditions/landed", false)
	_state.travel(&"jump")


func fall() -> void:
	if _state.get_current_node() == &"move":
		_state.travel(&"fall")


func land() -> void:
	if _state.get_current_node() in [&"jump", &"fall"]:
		set("parameters/conditions/landed", false)
		_state.travel(&"land")


## The landing is over, or the player moves on: back to locomotion.
func back_to_moving() -> void:
	set("parameters/conditions/landed", true)


func die() -> void:
	_state.travel(&"death")


func current_state() -> StringName:
	return _state.get_current_node()


# The jump clip is a whole standing jump: wind-up, take-off, flight, landing
# and recovery, with the height of the jump in the hips. Physics moves the
# body, so cut the clip into its parts and take the height out of the hips.
func _make_jump_clips(player: AnimationPlayer) -> void:
	var library := player.get_animation_library(&"")
	var source := player.get_animation(JUMP_CLIP)
	var parts := {
		&"jump_up": [0.70, 1.10, Animation.LOOP_NONE],
		&"jump_fall": [1.05, 1.20, Animation.LOOP_PINGPONG],
		&"jump_land": [1.35, 1.95, Animation.LOOP_NONE],
	}
	for part: StringName in parts:
		var span: Array = parts[part]
		var clip := _slice(source, span[0], span[1])
		clip.loop_mode = span[2]
		_flatten_hips(clip, _standing_hips_height(source))
		if library.has_animation(part):
			library.remove_animation(part)
		library.add_animation(part, clip)


# A copy of the clip between two times, starting at zero.
func _slice(source: Animation, from: float, to: float) -> Animation:
	var clip := Animation.new()
	clip.length = to - from
	for track in source.get_track_count():
		var type := source.track_get_type(track)
		var copy := clip.add_track(type)
		clip.track_set_path(copy, source.track_get_path(track))
		clip.track_set_interpolation_type(copy, source.track_get_interpolation_type(track))
		var time := from
		while time < to + 0.0001:
			var at := minf(time, to)
			match type:
				Animation.TYPE_POSITION_3D:
					clip.position_track_insert_key(copy, at - from, source.position_track_interpolate(track, at))
				Animation.TYPE_ROTATION_3D:
					clip.rotation_track_insert_key(copy, at - from, source.rotation_track_interpolate(track, at))
				Animation.TYPE_SCALE_3D:
					clip.scale_track_insert_key(copy, at - from, source.scale_track_interpolate(track, at))
				_:
					clip.track_insert_key(copy, at - from, source.value_track_interpolate(track, at))
			time += 1.0 / 30.0
	return clip


func _standing_hips_height(source: Animation) -> float:
	var track := _hips_track(source)
	return source.position_track_interpolate(track, 0.0).y


# Keep the crouch of the wind-up and the landing, but never let the hips rise
# above standing height: the body's velocity does the rising.
func _flatten_hips(clip: Animation, standing: float) -> void:
	var track := _hips_track(clip)
	for key in clip.track_get_key_count(track):
		var position: Vector3 = clip.track_get_key_value(track, key)
		position.y = minf(position.y, standing)
		clip.track_set_key_value(track, key, position)


func _hips_track(clip: Animation) -> int:
	for track in clip.get_track_count():
		var path := String(clip.track_get_path(track))
		if path.ends_with(":Hips") and clip.track_get_type(track) == Animation.TYPE_POSITION_3D:
			return track
	push_error("No position track for the Hips bone.")
	return -1


func _build_graph() -> AnimationNodeStateMachine:
	var machine := AnimationNodeStateMachine.new()
	machine.add_node(&"move", _build_move(), Vector2(0, 0))
	machine.add_node(&"jump", _clip(&"jump_up"), Vector2(300, -100))
	machine.add_node(&"fall", _clip(&"jump_fall"), Vector2(300, 100))
	machine.add_node(&"land", _clip(&"jump_land"), Vector2(600, 0))
	machine.add_node(&"death", _clip(&"death"), Vector2(0, 250))

	# Without Auto, the machine waits in Start and plays nothing.
	var start := _transition(0.0)
	start.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
	machine.add_transition(&"Start", &"move", start)
	machine.add_transition(&"move", &"jump", _transition(0.1))
	machine.add_transition(&"move", &"fall", _transition(0.2))
	# The take-off ends at the top of the jump: fall from there.
	var to_fall := _transition(0.1)
	to_fall.switch_mode = AnimationNodeStateMachineTransition.SWITCH_MODE_AT_END
	to_fall.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
	machine.add_transition(&"jump", &"fall", to_fall)
	machine.add_transition(&"jump", &"land", _transition(0.05))
	machine.add_transition(&"fall", &"land", _transition(0.05))
	machine.add_transition(&"land", &"jump", _transition(0.1))
	# One transition per pair of states: the player's code sets the condition
	# when the landing is over or the player moves on.
	var after_land := _transition(0.2)
	after_land.advance_mode = AnimationNodeStateMachineTransition.ADVANCE_MODE_AUTO
	after_land.advance_condition = &"landed"
	machine.add_transition(&"land", &"move", after_land)
	for from in [&"move", &"jump", &"fall", &"land"]:
		machine.add_transition(from, &"death", _transition(0.2))
	return machine


# Locomotion, then the attacks over it, then the hit reaction over everything.
func _build_move() -> AnimationNodeBlendTree:
	var tree := AnimationNodeBlendTree.new()

	var locomotion := AnimationNodeBlendSpace1D.new()
	locomotion.min_space = 0.0
	locomotion.max_space = run_speed * 1.5
	locomotion.add_blend_point(_clip(&"idle"), 0.0, -1, &"idle")
	locomotion.add_blend_point(_clip(&"walk"), walk_speed, -1, &"walk")
	locomotion.add_blend_point(_clip(&"run"), run_speed, -1, &"run")
	tree.add_node(&"locomotion", locomotion, Vector2(0, 0))

	var attacks := [&"slash", &"punch"]
	for choice: StringName in [&"attack_clip", &"upper_body_clip"]:
		tree.add_node(choice, _choice(attacks), Vector2(0, 200))
		for index in attacks.size():
			var input := StringName("%s_%s" % [choice, attacks[index]])
			tree.add_node(input, _clip(attacks[index]), Vector2(-250, 200 + index * 100))
			tree.connect_node(choice, index, input)

	var upper := AnimationNodeOneShot.new()
	upper.fadein_time = 0.1
	upper.fadeout_time = 0.2
	upper.filter_enabled = true
	for path in _upper_body_paths():
		upper.set_filter_path(path, true)
	tree.add_node(&"upper_body", upper, Vector2(300, 0))

	var full := AnimationNodeOneShot.new()
	full.fadein_time = 0.1
	full.fadeout_time = 0.2
	tree.add_node(&"full_body", full, Vector2(550, 0))

	var hit := AnimationNodeOneShot.new()
	hit.fadein_time = 0.05
	hit.fadeout_time = 0.25
	tree.add_node(&"hit", hit, Vector2(800, 0))
	tree.add_node(&"hit_clip", _clip(&"hit"), Vector2(550, 200))

	tree.connect_node(&"upper_body", 0, &"locomotion")
	tree.connect_node(&"upper_body", 1, &"upper_body_clip")
	tree.connect_node(&"full_body", 0, &"upper_body")
	tree.connect_node(&"full_body", 1, &"attack_clip")
	tree.connect_node(&"hit", 0, &"full_body")
	tree.connect_node(&"hit", 1, &"hit_clip")
	tree.connect_node(&"output", 0, &"hit")
	return tree


# A Transition node with one input for each clip, so code can pick the clip.
func _choice(clips: Array) -> AnimationNodeTransition:
	var choice := AnimationNodeTransition.new()
	choice.xfade_time = 0.0
	for clip: StringName in clips:
		choice.add_input(String(clip))
	return choice


func _clip(name: StringName) -> AnimationNodeAnimation:
	var node := AnimationNodeAnimation.new()
	node.animation = name
	return node


func _transition(xfade: float) -> AnimationNodeStateMachineTransition:
	var transition := AnimationNodeStateMachineTransition.new()
	transition.xfade_time = xfade
	return transition


# Track paths of the upper body bones, as the tree sees them.
func _upper_body_paths() -> Array[NodePath]:
	var character := get_node(root_node)
	var skeleton := character.find_child("Skeleton3D", true, false) as Skeleton3D
	var prefix := String(character.get_path_to(skeleton))
	var first := skeleton.find_bone(upper_body_bone)
	var paths: Array[NodePath] = []
	for bone in skeleton.get_bone_count():
		var parent := bone
		while parent != -1 and parent != first:
			parent = skeleton.get_bone_parent(parent)
		if parent == first:
			paths.append(NodePath("%s:%s" % [prefix, skeleton.get_bone_name(bone)]))
	return paths

player.gd

Attach it to the Player.

extends CharacterBody3D
## Third-person player: moves relative to the camera, turns to face where it
## goes, jumps with coyote time and a jump buffer, climbs low steps, and
## drives a CharacterAnimator.

@export_group("Movement")
@export var acceleration := 10.0 ## Metres per second, per second.
@export var air_control := 0.3 ## Share of the acceleration in the air.
@export var turn_speed := 10.0 ## Radians per second.
@export var jump_height := 1.0 ## Metres.
@export var coyote_time := 0.12 ## Seconds after a ledge when a jump still works.
@export var jump_buffer := 0.12 ## Seconds a jump press waits for the floor.
@export var step_height := 0.3 ## The highest step it walks up, in metres.
@export var landing_time := 0.5 ## Seconds the landing plays when standing still.
@export var step_catch_up := 1.5 ## How fast the model rises onto a step, in metres per second.

@export_group("Camera")
@export var mouse_sensitivity := 0.003 ## Radians per pixel.
@export var stick_sensitivity := 3.0 ## Radians per second at full tilt.
@export var camera_height := 1.5 ## The point the camera orbits, in metres.
@export var min_pitch := -1.2
@export var max_pitch := 0.4

@onready var model: Node3D = $character
@onready var animator: CharacterAnimator = $CharacterAnimator
@onready var pivot: Node3D = $CameraPivot
@onready var spring_arm: SpringArm3D = $CameraPivot/SpringArm3D

var _yaw := 0.0
var _pitch := -0.35
var _since_floor := 0.0
var _since_jump_press := INF
var _jumping := false
var _in_air := false
var _landed_for := 0.0
var _dead := false


func _ready() -> void:
	_add_default_inputs()
	Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
	spring_arm.add_excluded_object(get_rid())
	pivot.top_level = true
	# The camera moves every drawn frame in _process, already smooth: physics
	# interpolation must leave it alone.
	pivot.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
	floor_snap_length = step_height
	floor_constant_speed = true
	_yaw = model.global_rotation.y + PI


func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		_yaw -= event.relative.x * mouse_sensitivity
		_pitch = clampf(_pitch - event.relative.y * mouse_sensitivity, min_pitch, max_pitch)
	elif event.is_action_pressed(&"ui_cancel"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	elif event is InputEventMouseButton and event.pressed and Input.mouse_mode != Input.MOUSE_MODE_CAPTURED:
		# The click that takes the mouse back doesn't also attack.
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
		get_viewport().set_input_as_handled()


func _process(delta: float) -> void:
	var look := Input.get_vector(&"look_left", &"look_right", &"look_up", &"look_down")
	_yaw -= look.x * stick_sensitivity * delta
	_pitch = clampf(_pitch - look.y * stick_sensitivity * delta, min_pitch, max_pitch)
	# Follow the drawn position, not the physics position, or the camera jitters.
	pivot.global_position = get_global_transform_interpolated().origin + Vector3.UP * camera_height
	pivot.rotation = Vector3(_pitch, _yaw, 0.0)


func _physics_process(delta: float) -> void:
	if not is_on_floor():
		velocity += get_gravity() * delta
	if _dead:
		velocity.x = 0.0
		velocity.z = 0.0
		move_and_slide()
		return

	_update_jump(delta)
	var direction := _move_direction()
	_update_velocity(direction, delta)
	if direction.length() > 0.1:
		var facing := atan2(direction.x, direction.z)
		model.rotation.y = rotate_toward(model.rotation.y, facing, turn_speed * delta)

	_step_up(delta)
	move_and_slide()
	model.position.y = move_toward(model.position.y, 0.0, step_catch_up * delta)
	_update_animation(direction, delta)


func take_hit() -> void:
	if not _dead:
		animator.hit()


func die() -> void:
	_dead = true
	animator.die()


func _move_direction() -> Vector3:
	var input := Input.get_vector(&"move_left", &"move_right", &"move_forward", &"move_back")
	# Forward is where the camera looks, flattened onto the floor.
	return Basis(Vector3.UP, _yaw) * Vector3(input.x, 0.0, input.y)


func _update_velocity(direction: Vector3, delta: float) -> void:
	var top_speed := animator.run_speed if Input.is_action_pressed(&"sprint") else animator.walk_speed
	var target := direction * top_speed
	# A full-body attack plants the feet.
	if animator.get("parameters/move/full_body/active"):
		target = Vector3.ZERO
	var rate := acceleration if is_on_floor() else acceleration * air_control
	var horizontal := Vector3(velocity.x, 0.0, velocity.z).move_toward(target, rate * delta)
	velocity.x = horizontal.x
	velocity.z = horizontal.z


func _update_jump(delta: float) -> void:
	_since_floor = 0.0 if is_on_floor() else _since_floor + delta
	_since_jump_press += delta
	if Input.is_action_just_pressed(&"jump"):
		_since_jump_press = 0.0

	if _since_jump_press <= jump_buffer and _since_floor <= coyote_time and not _jumping:
		velocity.y = sqrt(2.0 * get_gravity().length() * jump_height)
		_jumping = true
		_since_jump_press = INF
		_since_floor = INF
		animator.jump()
	# Let go early for a lower jump.
	if Input.is_action_just_released(&"jump") and velocity.y > 0.0:
		velocity.y *= 0.5


func _update_animation(direction: Vector3, delta: float) -> void:
	# The speed the body really moved: against a wall, that's zero, so idle.
	var real := get_real_velocity()
	animator.set_speed(Vector2(real.x, real.z).length())

	if is_on_floor():
		if _in_air:
			_in_air = false
			_jumping = false
			_landed_for = 0.0
			animator.land()
		_landed_for += delta
		# Moving again cuts the landing short.
		if _landed_for > landing_time or (direction.length() > 0.1 and _landed_for > 0.1):
			animator.back_to_moving()
	else:
		_in_air = true
		if not _jumping and _since_floor > coyote_time:
			animator.fall()

	if Input.is_action_just_pressed(&"attack") and is_on_floor():
		animator.attack(&"slash", direction.length() > 0.1)


# CharacterBody3D slides along steps instead of climbing them. When the way
# ahead is blocked by something steep, try the same move one step higher,
# then put the body down on top of the step.
func _step_up(delta: float) -> void:
	if not is_on_floor():
		return
	var motion := Vector3(velocity.x, 0.0, velocity.z) * delta
	if motion.length() < 0.001:
		return
	var hit := KinematicCollision3D.new()
	if not test_move(global_transform, motion, hit):
		return
	if hit.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
		return # A slope: move_and_slide walks up it.
	var up := Vector3.UP * step_height
	if test_move(global_transform, up):
		return # No room above.
	var raised := global_transform.translated(up)
	if test_move(raised, motion):
		return # Too high: still blocked one step up.
	# Look for the top of the step one capsule radius ahead: the round bottom
	# of the capsule meets the edge of the step at a steep angle.
	var probe := motion.normalized() * (motion.length() + _radius())
	if test_move(raised, probe):
		return
	var down := KinematicCollision3D.new()
	if test_move(raised.translated(probe), -up, down):
		var rise := step_height - down.get_travel().length()
		if rise > 0.01 and down.get_normal().angle_to(Vector3.UP) <= floor_max_angle:
			global_position.y += rise
			# The body pops up in one frame. Keep the model where it was and
			# let it catch up, so the step looks like a step.
			model.position.y -= rise


func _radius() -> float:
	var capsule := $CollisionShape3D.shape as CapsuleShape3D
	return capsule.radius if capsule else 0.3


func _add_default_inputs() -> void:
	var keys := {
		&"move_forward": [KEY_W, KEY_UP], &"move_back": [KEY_S, KEY_DOWN],
		&"move_left": [KEY_A, KEY_LEFT], &"move_right": [KEY_D, KEY_RIGHT],
		&"jump": [KEY_SPACE], &"sprint": [KEY_SHIFT],
	}
	var axes := {
		&"move_left": [JOY_AXIS_LEFT_X, -1.0], &"move_right": [JOY_AXIS_LEFT_X, 1.0],
		&"move_forward": [JOY_AXIS_LEFT_Y, -1.0], &"move_back": [JOY_AXIS_LEFT_Y, 1.0],
		&"look_left": [JOY_AXIS_RIGHT_X, -1.0], &"look_right": [JOY_AXIS_RIGHT_X, 1.0],
		&"look_up": [JOY_AXIS_RIGHT_Y, -1.0], &"look_down": [JOY_AXIS_RIGHT_Y, 1.0],
	}
	var buttons := {
		&"jump": JOY_BUTTON_A, &"sprint": JOY_BUTTON_LEFT_STICK, &"attack": JOY_BUTTON_X,
	}
	for action: StringName in [&"move_forward", &"move_back", &"move_left", &"move_right",
			&"look_left", &"look_right", &"look_up", &"look_down", &"jump", &"sprint", &"attack"]:
		if InputMap.has_action(action):
			continue # Your own bindings from the Input Map win.
		InputMap.add_action(action, 0.2)
		for key: Key in keys.get(action, []):
			var event := InputEventKey.new()
			event.physical_keycode = key
			InputMap.action_add_event(action, event)
		if axes.has(action):
			var motion := InputEventJoypadMotion.new()
			motion.axis = axes[action][0]
			motion.axis_value = axes[action][1]
			InputMap.action_add_event(action, motion)
		if buttons.has(action):
			var button := InputEventJoypadButton.new()
			button.button_index = buttons[action]
			InputMap.action_add_event(action, button)
	if InputMap.action_get_events(&"attack").filter(func(e): return e is InputEventMouseButton).is_empty():
		var click := InputEventMouseButton.new()
		click.button_index = MOUSE_BUTTON_LEFT
		InputMap.action_add_event(&"attack", click)

Where to go from here

  • More moves. Add a clip as one more input on the attack Transition nodes, or one more blend point. A Hatchery3D character comes with all the clips this controller uses.
  • Strafing and aiming. Replace the BlendSpace1D with a BlendSpace2D of forward, back and side clips, and face the camera instead of the movement.
  • A weapon in the hand. The Godot import guide attaches a prop to the hand socket and closes the fingers around the grip.
  • A different character. Describe the next one well, with what to put in a 3D character prompt.