Skip to content

Articles

Godot AnimationTree: conditions and blend spaces for 2D

Drive a 2D character's AnimationTree from GDScript: a state machine whose transitions read conditions, a BlendSpace2D for eight directions, and the parameter names that must match.

An AnimationTree that "sometimes" plays the right animation is almost never a bug in the tree. It is a name mismatch, a condition that can never be true, or a blend position that was never set. All three are visible if you look at the parameters instead of the character.

This is the setup that removes all three: a state machine with explicit conditions, one BlendSpace2D per locomotion state, and a single script that owns every parameter write.

1. The tree shape

AnimationNodeStateMachine
├── Idle      (Animation)
├── Locomotion (BlendSpace2D)
├── Jump      (Animation)
└── Fall      (Animation)

Locomotion is one state, not four. Four directional states means four transitions per pair and sixteen chances to get a condition backwards. A blend space interpolates between the eight direction animations inside one state.

AnimatableBody-style tree nodes are not actors: the tree plays clips, the script decides which clip. Keep the decision in one function.

2. BlendSpace2D for eight directions

Inside the blend space, each animation is a point on a 2D plane:

Animation Blend position
walk_down (0, 0.65)
walk_up (0, -0.65)
walk_left (-0.65, 0)
walk_right (0.65, 0)
walk_down_right (0.45, 0.45)

The values do not need to be unit length — the node normalises when blending — but they must be consistent, because the same plane is what your code writes to:

@onready var tree: AnimationTree = $AnimationTree
@onready var state_machine: AnimationNodeStateMachinePlayback = tree.get("parameters/playback")

func _update_locomotion(direction: Vector2) -> void:
	# Normalised so a diagonal does not overshoot the plane.
	tree.set("parameters/Locomotion/blend_position", direction.normalized())

Write the blend position every frame while moving, and once more with Vector2.ZERO when the character stops. If you only write it while moving, the character stops in the last direction it walked — which looks like a bug in the idle animation.

3. Conditions are values, not magic strings

AnimationNodeStateMachineTransition has an advance_condition. Set it to a name, then write that same name as a boolean parameter:

# In the tree: Idle -> Locomotion has advance_condition "moving".
# Locomotion -> Idle has advance_condition "not_moving".
func _sync_conditions(velocity: Vector2, on_floor: bool) -> void:
	tree.set("parameters/moving", velocity.x != 0.0)
	tree.set("parameters/not_moving", velocity.x == 0.0)
	tree.set("parameters/in_air", not on_floor)

Two rules keep this honest:

  1. Every condition is written every frame. A condition nobody writes keeps its last value, and a stale true pins the state machine to one state.
  2. Conditions are mutually exclusive. If moving and not_moving can both be true, the transition taken depends on node order — and node order changes when you drag a node.

4. travel() versus conditions

state_machine.travel("Jump") interrupts immediately; a condition waits for the transition to be taken. Use travel() for events (jump, attack, death) and conditions for continuous state (idle versus locomotion). Mixing them is where "the jump animation does not play" comes from: a travel() is queued, then the next frame's condition transitions away before the clip starts.

func _on_jump() -> void:
	state_machine.travel("Jump")

Call it once per event. Calling it every frame while the jump key is held restarts the clip from zero, so the character jumps with a frozen first frame.

5. The three bugs, and how to see them before a player does

Symptom Cause Check
Nothing animates AnimationTree.active is false, or tree_root is unset Inspector, then print(tree.active)
Idle plays while sliding blend position keeps its last value write Vector2.ZERO on stop
Jump animation skipped travel() followed by a condition transition one frame of travel(), confirmed in the debugger
Wrong direction blend point typo, e.g. (0.65, 0) on walk_up click each point in the tree editor
Transition happens twice both conditions true make them exclusive

6. Test it without a controller

Set the parameters directly in a scratch scene and step frames — no input, no recording:

func _ready() -> void:
	tree.active = true
	_update_locomotion(Vector2.RIGHT)
	_set_conditions(Vector2.RIGHT, true)
	await get_tree().process_frame
	print(state_machine.get_current_node())   # expect Locomotion

That five-line check catches a parameter-name typo in seconds. A typo produces no error — set() on a missing parameter fails silently in release builds — so a named check is the only cheap way to find it.

Common mistakes

  • Assuming set() reports a bad name. It does not; an unset parameter stays at its default and the tree plays the wrong clip.
  • Renaming a node in the tree. Every script string that names it breaks silently at once.
  • One blend space for idle and walk. The idle frames blend with walk frames and the character moonwalks.
  • Driving the tree from _process with values computed in _physics_process. The tree reads a frame behind and foot contact flickers.
  • Forgetting tree.active = true. The node exists, plays nothing, and looks like a missing clip.