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:
- Every condition is written every frame. A condition nobody writes keeps its last value,
and a stale
truepins the state machine to one state. - Conditions are mutually exclusive. If
movingandnot_movingcan 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 LocomotionThat 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
_processwith 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.