Game Development with Godot & GDScript Module 8 – Animation and game feel
AnimationPlayer and animation tracks
Build Godot 4.7 animations from value, method and audio tracks, follow track paths, restore poses with RESET and test an AnimationPlayer frame by frame.
What you will learn
- Build animations in code from value tracks and predict what each interpolation gives between keys
- Resolve track paths from the root node and spot a path that animates the wrong node
- Choose between value, method and audio tracks, and decide when method calls must run at once
- Restore an interrupted animation with the RESET animation, and predict what deterministic blending does
- Test animations deterministically by advancing an AnimationPlayer by hand
Before you start
What you’ll learn · Before you start
What you will learn
- Build animations in code from value tracks and predict what each interpolation gives between keys
- Resolve track paths from the root node and spot a path that animates the wrong node
- Choose between value, method and audio tracks, and decide when method calls must run at once
- Restore an interrupted animation with the RESET animation, and predict what deterministic blending does
- Test animations deterministically by advancing an AnimationPlayer by hand
Before you start
On this page
A sword swing that opens its hitbox from 0.10 s to 0.20 s should be open for 6 ticks at 60 ticks a second. Stepped through an AnimationPlayer one tick at a time, with its method calls left at their default, and read after each tick, it was open after none of them: the calls that open and close it waited for the end of the frame and ran together. Bugs of this kind, and track paths that move the wrong node without a word, are why animations need tests as much as code does.
This page builds animations in code, so that every key is in plain sight: value tracks and how they fill the time between keys, the paths that tell a track which node to change, method and audio tracks, libraries and the RESET animation that puts an interrupted attack back, and tests that step an AnimationPlayer by hand. Each number quoted here was printed by Godot 4.7.2, run headless.
An animation is a set of tracks, each read at the playhead
A box that slides to x = 10 at 0.5 s and back to 4 at 1.0 s is one track (animation): One line of an Animation: a path to a node and a property, a method, a sound or another player, with keys at times. At the playhead each track gives its value or makes its call. with three
key (keyframe): A value, a call or a sound stored at one time on an animation track. A value track fills the time between two keys by its interpolation; a method or audio key acts when the playhead passes it.. An Animation resource holds such tracks; each names a node and a property, and holds
keys, each a time and a value. An AnimationPlayer keeps animations and moves a playhead through one of them. At any
time it asks every track for its value there and sets the property. Between two keys the track’s
interpolation (of a track): How a value track works out the values between its keys: Linear at a steady rate, Cubic slower near the keys and faster between them, Nearest holding each key's value until the next key. decides the value. The editor builds the same resource that this
program builds in code: three tracks with the same keys and three interpolations, and two tracks that key a sprite’s
frame.
extends SceneTree
# Builds an animation in code and reads what it gives at nine times. Three value tracks share the keys 0 at 0.0 s,
# 10 at 0.5 s and 4 at 1.0 s, each with its own interpolation; two tracks key a Sprite2D's frame, 0 at 0.0 s and 3 at
# 0.6 s, one discrete and one continuous. The player is driven by hand: advance() moves the playhead and applies
# every track at once.
const KEYS := [[0.0, 0.0], [0.5, 10.0], [1.0, 4.0]]
const KINDS := {
"Nearest": Animation.INTERPOLATION_NEAREST,
"Linear": Animation.INTERPOLATION_LINEAR,
"Cubic": Animation.INTERPOLATION_CUBIC,
}
func _initialize() -> void:
var stage := Node2D.new()
root.add_child(stage)
var slide := Animation.new()
slide.length = 1.0
for kind: String in KINDS:
var box := Node2D.new()
box.name = kind
stage.add_child(box)
var track := slide.add_track(Animation.TYPE_VALUE)
slide.track_set_path(track, kind + ":position:x")
slide.track_set_interpolation_type(track, KINDS[kind])
for key: Array in KEYS:
slide.track_insert_key(track, key[0], key[1])
for mode: String in ["Discrete", "Continuous"]:
var sprite := Sprite2D.new()
sprite.name = mode
sprite.hframes = 4
stage.add_child(sprite)
var track := slide.add_track(Animation.TYPE_VALUE)
slide.track_set_path(track, mode + ":frame")
slide.value_track_set_update_mode(track, Animation.UPDATE_DISCRETE if mode == "Discrete" else Animation.UPDATE_CONTINUOUS)
slide.track_insert_key(track, 0.0, 0)
slide.track_insert_key(track, 0.6, 3)
var library := AnimationLibrary.new()
library.add_animation(&"slide", slide)
var player := AnimationPlayer.new()
stage.add_child(player) # its root node, "..", is stage, so "Linear:position:x" means stage/Linear
player.add_animation_library(&"", library)
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
player.play(&"slide")
player.advance(0.0)
print("time Nearest Linear Cubic frame: discrete continuous")
for step in 9:
print("%.3f s %6.3f %6.3f %6.3f %15d %10d" % [
player.current_animation_position,
stage.get_node("Nearest").position.x, stage.get_node("Linear").position.x, stage.get_node("Cubic").position.x,
stage.get_node("Discrete").frame, stage.get_node("Continuous").frame,
])
player.advance(0.125)
print("value_track_interpolate() of the linear track at 0.25 s: ", slide.value_track_interpolate(1, 0.25))
stage.free()
quit() from session import files, run
files("project.godot", "slide_anim.gd")
run("""
godot --headless --no-header --script slide_anim.gd
""") Output
$ godot --headless --no-header --script slide_anim.gd time Nearest Linear Cubic frame: discrete continuous 0.000 s 0.000 0.000 0.000 0 0 0.125 s 0.000 2.500 1.469 0 1 0.250 s 0.000 5.000 4.750 0 1 0.375 s 0.000 7.500 8.156 0 2 0.500 s 10.000 10.000 10.000 0 2 0.625 s 10.000 8.500 9.344 3 3 0.750 s 10.000 7.000 7.250 3 3 0.875 s 10.000 5.500 5.031 3 3 1.000 s 4.000 4.000 4.000 3 3 value_track_interpolate() of the linear track at 0.25 s: 5.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 slide_session.py
Follow the three lines between the keys:
The values three interpolations gave between the same three keys
Text description of the diagram
The chart plots position:x against time from 0 to 1 s for three value tracks with the same keys: 0 at 0.0 s, 10 at 0.5 s and 4 at 1.0 s. The marks are the values the run printed every 0.125 s.
- Linear (solid line, circles): 0, 2.5, 5.0, 7.5, 10.0, then 8.5, 7.0, 5.5 and 4.0, a straight line between keys.
- Cubic (dashed line, squares): 0, 1.469, 4.75, 8.156, 10.0, then 9.344, 7.25, 5.031 and 4.0, slower near the keys and faster between them.
- Nearest (dotted step, diamonds): 0 until 0.5 s, 10 from 0.5 s until 1.0 s, then 4. It holds each key's value until the next key instead of switching halfway between keys.
A dashed vertical line marks 0.25 s, where the three tracks gave 0, 5.0 and 4.75.
Linear moved at a steady rate: a quarter of the way from 0 to 10, at 0.125 s, it gave 2.5. Cubic eased out of one key
and into the next, below the straight line at 0.125 s with 1.469 and above it at 0.375 s with 8.156, a soft start and
stop that suits a character’s limbs. Nearest is not what its name suggests: in this run it held each key until the
next one, 0 until 0.5 s and 10 until 1.0 s, rather than switching halfway. value_track_interpolate() reads a track
without playing it, 5.0 for the linear one at 0.25 s, which helps in tests.
The frame tracks show the other setting of a value track, its update mode. The discrete track jumped from frame 0 to
frame 3 at the key at 0.6 s; the continuous one treated the frame number as a quantity to interpolate and showed
frames 1 and 2 on the way.
A frame track left continuous
A sprite whose frame track updates continuously shows every frame number between two keys, so a cut from frame 0
to frame 3 plays 1 and 2 first, as the run showed. Set such tracks to Animation.UPDATE_DISCRETE, Discrete in the
track’s settings, and the frame changes only at its keys.
Checkpoint
Check: Tracks, keys and interpolation
Track paths start at the player’s parent
A track’s path names a node and then, after a colon, a property: Sword:position:x is the x of Sword’s
position. The node part is read from the AnimationPlayer’s root node (of an AnimationPlayer): AnimationMixer.root_node, the node from which every track path is read. It is ".." by default, the player's parent, so "." in a path is the parent and Sword is the parent's child Sword.,
root_node, which is ".." by default: one step up from the player, its parent. This run puts the player under Hero
and tries five paths, then runs Hero as a scene of its own:
extends SceneTree
# Where track paths lead. Each value track adds 10 to an x over one second, on an AnimationPlayer that is a child of
# Hero in this tree: root (the window) > World > Hero > Sword and AnimationPlayer.
# A track's path is read from the player's root_node, which is ".." by default: the player's parent, Hero.
func _initialize() -> void:
var world := Node2D.new()
world.name = "World"
var hero := Node2D.new()
hero.name = "Hero"
var sword := Node2D.new()
sword.name = "Sword"
var player := AnimationPlayer.new()
world.add_child(hero)
hero.add_child(sword)
hero.add_child(player)
root.add_child(world)
print("root_node is \"%s\": %s" % [player.root_node, player.get_node(player.root_node).name])
play_paths(player, [".:position:x", "Sword:position:x", "..:position:x", "Sward:position:x", ".:positon:x"])
print("after 0.5 s: World x = %.1f, Hero x = %.1f, Sword x = %.1f" % [world.position.x, hero.position.x, sword.position.x])
# Hero as the root of its own scene, as when that scene runs on its own: its parent is now the window.
var alone := Node2D.new()
alone.name = "Hero"
var player2 := AnimationPlayer.new()
alone.add_child(player2)
root.add_child(alone)
print("Hero on its own: \"..\" from Hero is %s, a %s" % [alone.get_node("..").name, alone.get_node("..").get_class()])
play_paths(player2, ["..:position:x"])
print("after 0.5 s: the window's x = %d" % root.position.x)
world.free()
alone.free()
quit()
func play_paths(player: AnimationPlayer, paths: Array) -> void:
var slide := Animation.new()
for path: String in paths:
var track := slide.add_track(Animation.TYPE_VALUE)
slide.track_set_path(track, path)
slide.track_insert_key(track, 0.0, 0.0)
slide.track_insert_key(track, 1.0, 10.0)
var library := AnimationLibrary.new()
library.add_animation(&"slide", slide)
player.add_animation_library(&"", library)
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
player.play(&"slide")
player.advance(0.5) from session import files, run
files("project.godot", "paths.gd")
run("""
godot --headless --no-header --script paths.gd
""") Output
$ godot --headless --no-header --script paths.gd
root_node is "..": Hero
WARNING: AnimationMixer: 'slide', couldn't resolve track: 'Sward:position:x'. This warning can be disabled in Project Settings.
at: _update_caches (scene/animation/animation_mixer.cpp:725)
GDScript backtrace (most recent call first):
[0] play_paths (res://paths.gd:50)
[1] _initialize (res://paths.gd:21)
WARNING: AnimationMixer: 'slide', couldn't resolve track: '.:positon:x'. This warning can be disabled in Project Settings.
at: _update_caches (scene/animation/animation_mixer.cpp:725)
GDScript backtrace (most recent call first):
[0] play_paths (res://paths.gd:50)
[1] _initialize (res://paths.gd:21)
after 0.5 s: World x = 5.0, Hero x = 5.0, Sword x = 5.0
Hero on its own: ".." from Hero is root, a Window
after 0.5 s: the window's x = 5
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paths_session.py
Step through where each path went:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The root node. An AnimationPlayer reads every track path from its root_node, which is ".." by default, one step up from the player. Here that is Hero, as the run printed.
- ".:position:x". The dot is the root node itself, so this track moved Hero. Half a second into a track keyed 0 at 0 s and 10 at 1 s, Hero's x was 5.0.
- "Sword:position:x". A name goes down from the root node to a child of that name. Sword's x was 5.0 too.
- "..:position:x". Two dots go one step up from the root node, to World, which moved as well. A path can climb out of the node you meant to animate without any warning.
- Sward and positon. A misspelt node, Sward, and a misspelt property, positon, lead nowhere. Godot printed "couldn't resolve track" for each, a warning that the project setting animation/warnings/check_invalid_track_paths keeps on by default.
- Hero on its own. When Hero's scene runs alone, Hero is a child of the window. The same "..:position:x" then reached the window and moved it to x = 5, again with no warning, because the path did resolve.
Three tracks found a node: . is Hero itself, Sword is Hero’s child, and .. climbed one more step, to World. The
misspelt node Sward and the misspelt property positon led nowhere, and Godot printed “couldn’t resolve track” for
each. That warning comes from the project setting animation/warnings/check_invalid_track_paths, on by default.
The dangerous path is one that does resolve, to the wrong node. When Hero’s scene ran on its own, its parent was the
window, and ..:position:x moved the window to x = 5 with no warning at all. Keep every path inside the scene that
holds the player, from its root node down, and the scene behaves the same wherever it is instanced.
Key idea
A track path starts at the player’s root node, its parent by default. A path that leads nowhere prints a warning; a path that leads to the wrong node prints nothing and animates it.
Checkpoint
Check: Track paths and the root node
Each track type changes the scene in its own way
A value track sets a property, but an animation can do more along its timeline. The Animation class reference lists nine track types:
| Track type | What it does at the playhead | Typical use |
|---|---|---|
| Value | sets a property, interpolated or discrete | positions, colours, a sprite’s frame |
| Position, Rotation, Scale 3D | sets a 3D node’s transform parts, and can be compressed | imported 3D animations |
| Blend shape | sets a blend shape of a MeshInstance3D | faces |
| Method call | calls a method, with the key’s arguments, when the playhead passes the key | hitboxes, footsteps, spawning |
| Bezier curve | sets a property along a curve with handles | hand-shaped motion |
| Audio playback | starts a sound on an AudioStreamPlayer, 2D or 3D | sounds in step with the motion |
| Animation playback | plays an animation of another AnimationPlayer | cutscenes with several characters |
Two notes on the track types page are worth knowing early: method tracks do not run when an animation is previewed in the editor, and bezier tracks cannot be blended with value tracks. The audio track has a habit of its own, which the next run shows. It plays the attack’s swoosh at 0.10 s twice: once on Swoosh, a player of its own, and once on Voice, a player that also holds the hero’s shout:
extends Node2D
# The hero of this page's examples. Its attack opens and closes the hitbox by calling these methods, and every call
# is written to calls, so a run can print what happened and when.
var hitbox_open := false
var calls: Array[String] = []
func open_hitbox() -> void:
hitbox_open = true
calls.append("open_hitbox")
func close_hitbox() -> void:
hitbox_open = false
calls.append("close_hitbox")
func footstep(foot: String) -> void:
calls.append("footstep(%s)" % foot) extends SceneTree
# A 0.4 s attack: a method track opens the hitbox at 0.10 s, closes it at 0.20 s and calls footstep("left") at 0.30 s;
# an audio track starts a swoosh at 0.10 s. The player is advanced by hand, one 1/60 s tick at a time, and the hitbox
# is read right after each tick, as the hero's own code or a test reads it.
const TICK := 1.0 / 60.0
func _initialize() -> void:
await process_frame # method tracks call nothing until the nodes are in the tree, which they join on frame 1
for mode: int in [AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_DEFERRED, AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_IMMEDIATE]:
var hero := make_hero("Swoosh")
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
player.callback_mode_method = mode
player.play(&"attack")
var open_ticks: Array[int] = []
for tick in range(1, 25):
player.advance(TICK)
if hero.hitbox_open:
open_ticks.append(tick)
var label := "deferred (the default)" if mode == AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_DEFERRED else "immediate"
print("%s: open after ticks %s; calls made by tick 24: %s" % [label, open_ticks if open_ticks else "none", hero.calls])
await process_frame
print(" calls once the frame had ended: %s" % [hero.calls])
hero.free()
# The audio track takes over the player it names. Swoosh is a player of its own; Voice also plays the hero's shout.
for target: String in ["Swoosh", "Voice"]:
var hero := make_hero(target)
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
var sfx: AudioStreamPlayer = hero.get_node(target)
var seen: Array[String] = []
player.play(&"attack")
for tick in range(1, 25):
player.advance(TICK)
if tick in [5, 6, 23, 24]:
seen.append("tick %d: %s" % [tick, sfx.stream.get_class() if sfx.stream else "no stream"])
var voice: AudioStreamPlayer = hero.get_node("Voice")
print("audio track on %s: %s" % [target, ", ".join(seen)])
print(" the shout on Voice afterwards: %s" % ["still there" if voice.stream else "gone"])
hero.free()
await create_timer(0.25).timeout # gives the audio server time to release the freed players' sounds
quit()
func make_hero(sound_target: String) -> Node2D:
var hero := Node2D.new()
hero.set_script(load("res://hero.gd"))
for player_name: String in ["Swoosh", "Voice"]:
var sfx := AudioStreamPlayer.new()
sfx.name = player_name
hero.add_child(sfx)
hero.get_node("Voice").stream = silence(0.5) # the hero's shout, which code plays on Voice
var attack := Animation.new()
attack.length = 0.4
var calls := attack.add_track(Animation.TYPE_METHOD)
attack.track_set_path(calls, ".")
attack.track_insert_key(calls, 0.10, {"method": &"open_hitbox", "args": []})
attack.track_insert_key(calls, 0.20, {"method": &"close_hitbox", "args": []})
attack.track_insert_key(calls, 0.30, {"method": &"footstep", "args": ["left"]})
var sound := attack.add_track(Animation.TYPE_AUDIO)
attack.track_set_path(sound, sound_target)
attack.audio_track_insert_key(sound, 0.10, silence(0.1))
var library := AnimationLibrary.new()
library.add_animation(&"attack", attack)
var player := AnimationPlayer.new()
player.name = "AnimationPlayer"
player.add_animation_library(&"", library)
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
hero.add_child(player)
root.add_child(hero)
return hero
# A silent sound of the given length, so the example needs no audio file.
func silence(seconds: float) -> AudioStreamWAV:
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = 22050
var data := PackedByteArray()
data.resize(int(22050 * seconds) * 2)
wav.data = data
return wav from session import files, run
files("project.godot", "hero.gd", "attack.gd")
run("""
godot --headless --no-header --script attack.gd
""") Output
$ godot --headless --no-header --script attack.gd deferred (the default): open after ticks none; calls made by tick 24: [] calls once the frame had ended: ["open_hitbox", "close_hitbox", "footstep(left)"] immediate: open after ticks [6, 7, 8, 9, 10, 11]; calls made by tick 24: ["open_hitbox", "close_hitbox", "footstep(left)"] calls once the frame had ended: ["open_hitbox", "close_hitbox", "footstep(left)"] audio track on Swoosh: tick 5: no stream, tick 6: AudioStreamPolyphonic, tick 23: AudioStreamPolyphonic, tick 24: no stream the shout on Voice afterwards: still there audio track on Voice: tick 5: AudioStreamWAV, tick 6: AudioStreamPolyphonic, tick 23: AudioStreamPolyphonic, tick 24: no stream the shout on Voice afterwards: gone
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 attack_session.py
Read the last four lines. When the playhead reached the sound at tick 6, the audio track took over the player it
names: Swoosh’s stream became an AudioStreamPolyphonic, through which the animation plays up to
audio_max_polyphony sounds at once, 32 by default, and at the end of the attack, tick 24, the player had no stream at
all. On Voice the same takeover removed the shout, so code
that later calls $Voice.play() plays nothing.
An audio track on a player that code also uses
An audio track replaces the stream of the player it names while it plays, and in this run left none behind. Give each audio track an AudioStreamPlayer of its own, such as Swoosh, and keep the players your code fills, such as Voice, out of animations.
Checkpoint
Check: Track types and audio tracks
Method tracks fire events, at the end of the frame by default
A method track is the way to make something happen at an exact moment of an animation: open a hitbox on the frame the
sword is out, play a footstep when the foot lands, spawn an arrow. Each key holds a method name and its arguments,
like footstep("left"), and the player calls the method on the track’s node when the playhead passes the key. When
it calls is set by callback_mode_method. The default, deferred, collects the calls and makes them once the frame’s
other work is done; the AnimationMixer reference says this avoids bugs when a call deletes a node or changes the
player while it plays. Immediate makes each call as the key is reached.
The first four lines of the run above compare them. In deferred mode the hitbox was closed after all 24 ticks, and the three calls ran together when the frame ended. In immediate mode it was open after ticks 6 to 11, from 0.100 s to 0.183 s, as keyed. Step through tick 6 to see why:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Tick 6. Tick 6 calls advance(1.0 / 60.0). The playhead moves from 5/60 s to 6/60 s, 0.100 s, and passes the key that calls open_hitbox().
- Values first. Value tracks are applied inside the same call, so a position or a frame that this animation keys already has its new value when advance() returns.
- Deferred: the call waits. In deferred mode, the default, the player does not call open_hitbox() now. It queues the call for the end of the frame.
- The test reads false. The test reads hero.hitbox_open after the tick and finds false. In the run it found false after all 24 ticks, because no frame ended between them.
- The frame ends. When the frame ended, the queued calls ran in key order, open_hitbox(), close_hitbox() and footstep("left"), all in one moment. No code saw the hitbox open.
- Immediate: the call happens. In immediate mode the player calls open_hitbox() inside advance(), as it passes the key. The test then read true after ticks 6 to 11, 0.100 s to 0.183 s.
Deferred calls are safe for a call that frees nodes, such as a death animation’s queue_free(). They are wrong for
anything your code reads in the same frame, a hit test in _physics_process() or a test that checks the hitbox: set
callback_mode_method to AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_IMMEDIATE there.
Key idea
A method key is an event: the player calls it when the playhead passes the key, at once or at the end of the frame, and nothing undoes it when the animation stops.
Checkpoint
Check: Method keys, deferred or immediate
Libraries name animations, and RESET puts a scene back at rest
An AnimationPlayer keeps its animations in animation library: An AnimationLibrary resource that groups animations. The global library has an empty name; an animation called attack in a library called combat is played as combat/attack.. The global
library has an empty name, shown as [Global] in the editor, and its animations are played by their own names; an
animation in a library called combat is played as combat/attack. Libraries are resources, so one library can serve
every enemy that shares its moves.
One animation name is special. The RESET animation: An animation named RESET in the global library, with one key at 0 s for each animated property at its rest value. The editor saves scenes with its values, and deterministic blending starts from them. holds the rest value of each
property the other animations change, one key each at 0 s. The editor offers to create it when you key a property
from the Inspector, and with reset_on_save, on by default, it saves the scene with RESET’s values while the editor
keeps showing the pose you were editing. At run time it matters when an animation is cut short. Here a hit at 0.15 s
interrupts the attack:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The attack starts. The hero attacks. Your code plays the combat library's attack, 0.4 s long.
- 0.10 s. At 0.10 s the method track calls open_hitbox(). The hitbox is open, and only the call keyed at 0.20 s would close it.
- 0.15 s. At 0.15 s the value tracks have turned the sword to 67.5 degrees and squashed the hero to a scale of (1.15, 0.85).
- A hit. An enemy hits the hero, and your code plays hurt. hurt keys one property, the hero's colour, from red back to white.
- hurt plays. Nothing in hurt touches the hitbox, the sword or the scale, so the player leaves them as the attack left them. The run printed hitbox open, sword 67.5 degrees and scale (1.15, 0.85) after hurt.
- RESET first. The fix is to put the attack back before the next animation. RESET keys all three at their rest values, so play RESET and advance(0) closed the hitbox, set the sword to 0 degrees and the scale to (1, 1).
interrupt.gd measures what is left of the attack in five set-ups, halfway through hurt and after it:
extends SceneTree
# The attack is interrupted at 0.15 s by a hit: the hitbox is open, the sword is at 67.5 degrees and the hero is
# squashed. "hurt" (0.3 s) keys only the hero's colour. What is left of the attack halfway through hurt, and once it
# has finished, in five set-ups?
const TICK := 1.0 / 60.0
func _initialize() -> void:
await process_frame # the nodes join the tree on frame 1; method tracks need that
for setup: String in ["as it is", "RESET played first", "deterministic, RESET", "deterministic, no RESET", "RESET in combat"]:
var hero := make_hero(setup)
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
if setup.begins_with("deterministic"):
player.deterministic = true
player.play(&"combat/attack")
for tick in 9:
player.advance(TICK)
if setup == "RESET played first":
player.play(&"RESET")
player.advance(0.0)
player.play(&"combat/hurt")
var seen: Array[String] = []
for tick in range(1, 25):
player.advance(TICK)
if tick == 9 or tick == 24:
seen.append(state(hero))
print("%s\n halfway through hurt: %s\n after hurt: %s" % [setup, seen[0], seen[1]])
if setup == "as it is":
print(" (the player's animations: %s)" % [player.get_animation_list()])
hero.free()
quit()
func state(hero: Node2D) -> String:
var sword: Node2D = hero.get_node("Sword")
return "hitbox %-6s sword %4.1f°, scale %s" % ["open," if hero.hitbox_open else "closed,", sword.rotation_degrees, hero.scale]
func make_hero(setup: String) -> Node2D:
var hero := Node2D.new()
hero.set_script(load("res://hero.gd"))
var sword := Node2D.new()
sword.name = "Sword"
hero.add_child(sword)
var attack := Animation.new()
attack.length = 0.4
var calls := attack.add_track(Animation.TYPE_METHOD)
attack.track_set_path(calls, ".")
attack.track_insert_key(calls, 0.10, {"method": &"open_hitbox", "args": []})
attack.track_insert_key(calls, 0.20, {"method": &"close_hitbox", "args": []})
add_value_track(attack, "Sword:rotation_degrees", [[0.0, 0.0], [0.2, 90.0], [0.4, 0.0]])
add_value_track(attack, ".:scale", [[0.0, Vector2.ONE], [0.2, Vector2(1.2, 0.8)], [0.4, Vector2.ONE]])
var hurt := Animation.new()
hurt.length = 0.3
add_value_track(hurt, ".:modulate", [[0.0, Color.RED], [0.3, Color.WHITE]])
var combat := AnimationLibrary.new()
combat.add_animation(&"attack", attack)
combat.add_animation(&"hurt", hurt)
var player := AnimationPlayer.new()
player.name = "AnimationPlayer"
player.add_animation_library(&"combat", combat)
if setup != "deterministic, no RESET":
# RESET: every property the animations change, at its value at rest; one key each, at 0.0 s.
var reset := Animation.new()
reset.length = 0.001
add_value_track(reset, ".:hitbox_open", [[0.0, false]])
add_value_track(reset, "Sword:rotation_degrees", [[0.0, 0.0]])
add_value_track(reset, ".:scale", [[0.0, Vector2.ONE]])
if setup == "RESET in combat":
combat.add_animation(&"RESET", reset)
player.deterministic = true
else:
var global := AnimationLibrary.new()
global.add_animation(&"RESET", reset)
player.add_animation_library(&"", global)
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
player.callback_mode_method = AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_IMMEDIATE
hero.add_child(player)
root.add_child(hero)
return hero
func add_value_track(anim: Animation, path: String, keys: Array) -> void:
var track := anim.add_track(Animation.TYPE_VALUE)
anim.track_set_path(track, path)
for key: Array in keys:
anim.track_insert_key(track, key[0], key[1]) from session import files, run
files("project.godot", "hero.gd", "interrupt.gd")
run("""
godot --headless --no-header --script interrupt.gd
""") Output
$ godot --headless --no-header --script interrupt.gd as it is halfway through hurt: hitbox open, sword 67.5°, scale (1.15, 0.85) after hurt: hitbox open, sword 67.5°, scale (1.15, 0.85) (the player's animations: ["RESET", "combat/attack", "combat/hurt"]) RESET played first halfway through hurt: hitbox closed, sword 0.0°, scale (1.0, 1.0) after hurt: hitbox closed, sword 0.0°, scale (1.0, 1.0) deterministic, RESET halfway through hurt: hitbox open, sword 0.0°, scale (1.0, 1.0) after hurt: hitbox closed, sword 0.0°, scale (1.0, 1.0) deterministic, no RESET halfway through hurt: hitbox open, sword 0.0°, scale (0.00001, 0.00001) after hurt: hitbox open, sword 0.0°, scale (0.00001, 0.00001) RESET in combat halfway through hurt: hitbox open, sword 0.0°, scale (0.00001, 0.00001) after hurt: hitbox closed, sword 0.0°, scale (0.00001, 0.00001)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 interrupt_session.py
| Set-up | Halfway through hurt | After hurt |
|---|---|---|
| As it is | open, 67.5°, squashed | open, 67.5°, squashed |
| RESET played first | closed, 0°, scale 1 | closed, 0°, scale 1 |
| Deterministic, with RESET | open, 0°, scale 1 | closed, 0°, scale 1 |
| Deterministic, no RESET | open, 0°, scale 0.00001 | open, 0°, scale 0.00001 |
| RESET in the combat library | open, 0°, scale 0.00001 | closed, 0°, scale 0.00001 |
As it is, the player left every property where the attack put it: the sword stayed out and the hitbox open. Playing
RESET and calling advance(0.0) before hurt put all three back at once. deterministic blending: AnimationMixer.deterministic: every frame starts from the RESET animation's values, or from 0 without one, and adds what the playing animations key. Off by default on AnimationPlayer, on for AnimationTree., deterministic = true, works differently: each frame starts from RESET’s values and adds what the
playing animations key, so the sword and the scale went back without a call, and the hitbox, which only a method had
opened, closed once hurt had finished. Without a RESET in the global library the starting values are 0: the hero’s
scale fell to 0.00001 and it vanished. A RESET in the combat library is combat/RESET, which the player does not
treat as RESET.
Put an interrupted animation back on purpose
Wherever your code cuts an animation short, play RESET and call advance(0.0) before the next one: in the run that
closed the hitbox and put the sword and the scale back before hurt began. At the least, close the hitbox there.
Checkpoint
Check: Libraries, RESET and interrupted animations
Tests drive the player by hand, one tick at a time
An animation test has to know exactly where the playhead is. Set callback_mode_process to
AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL and the player stops moving by itself; advance(delta) then
moves it by delta seconds and applies every track, as one frame would. seek(seconds, true) jumps to a time and
applies the tracks at once, but it does not replay what it jumps over. This test checks the attack at five times and
four behaviours a test relies on:
extends SceneTree
# Tests the attack the way a unit test would: the player is driven by hand (callback_mode_process = MANUAL), its
# method calls are made at once (callback_mode_method = IMMEDIATE), and every check names the time it reads.
# godot --headless --no-header --script test_attack.gd
var passed := 0
var checks := 0
func _initialize() -> void:
var early := make_hero()
early.get_node("AnimationPlayer").play(&"attack")
early.get_node("AnimationPlayer").advance(0.15)
print("Before the first frame, advance(0.15) made these calls: %s" % [early.calls])
early.free()
await process_frame # from here on, the nodes are in the tree
for t: float in [0.05, 0.10, 0.15, 0.20, 0.30]:
var hero := make_hero()
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
player.play(&"attack")
for tick in roundi(t * 60.0):
player.advance(1.0 / 60.0)
var want := t >= 0.10 and t < 0.20
check("at %.2f s the hitbox is %s" % [t, "open" if want else "closed"], hero.hitbox_open == want)
hero.free()
var hero := make_hero()
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
player.play(&"attack")
player.advance(0.15)
check("one advance(0.15) still makes the call keyed at 0.10 s", hero.calls == ["open_hitbox"])
hero.free()
# A seek is a jump, not a replay: from 0 s to 0.25 s it passes both keys of the method track.
hero = make_hero()
player = hero.get_node("AnimationPlayer")
var finished: Array[StringName] = []
player.animation_finished.connect(func(anim_name: StringName) -> void: finished.append(anim_name))
player.play(&"attack")
player.seek(0.25, true)
check("seek(0.25, true) from 0 s makes only the call keyed at 0.20 s", hero.calls == ["close_hitbox"])
player.seek(0.4, true)
check("seek() to the end stops the player without animation_finished", not player.is_playing() and finished.is_empty())
player.stop()
player.play(&"attack")
player.advance(0.5)
check("advance() past the end emits animation_finished, once", finished == [&"attack"])
hero.free()
print("%d of %d checks pass" % [passed, checks])
quit(0 if passed == checks else 1)
func check(what: String, ok: bool) -> void:
checks += 1
if ok:
passed += 1
print("%s %s" % ["ok " if ok else "FAIL", what])
func make_hero() -> Node2D:
var hero := Node2D.new()
hero.set_script(load("res://hero.gd"))
var attack := Animation.new()
attack.length = 0.4
var calls := attack.add_track(Animation.TYPE_METHOD)
attack.track_set_path(calls, ".")
attack.track_insert_key(calls, 0.10, {"method": &"open_hitbox", "args": []})
attack.track_insert_key(calls, 0.20, {"method": &"close_hitbox", "args": []})
var library := AnimationLibrary.new()
library.add_animation(&"attack", attack)
var player := AnimationPlayer.new()
player.name = "AnimationPlayer"
player.add_animation_library(&"", library)
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
player.callback_mode_method = AnimationMixer.ANIMATION_CALLBACK_MODE_METHOD_IMMEDIATE
hero.add_child(player)
root.add_child(hero)
return hero from session import files, run
files("project.godot", "hero.gd", "test_attack.gd")
run("""
godot --headless --no-header --script test_attack.gd
""") Output
$ godot --headless --no-header --script test_attack.gd Before the first frame, advance(0.15) made these calls: [] ok at 0.05 s the hitbox is closed ok at 0.10 s the hitbox is open ok at 0.15 s the hitbox is open ok at 0.20 s the hitbox is closed ok at 0.30 s the hitbox is closed ok one advance(0.15) still makes the call keyed at 0.10 s ok seek(0.25, true) from 0 s makes only the call keyed at 0.20 s ok seek() to the end stops the player without animation_finished ok advance() past the end emits animation_finished, once 9 of 9 checks pass
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 test_session.py
The first line is a trap for test scripts. A script that extends SceneTree runs _initialize() before the root
window is in the tree, and until the nodes join it, on the first frame, method tracks call nothing: advance(0.15)
made no calls at all. Value tracks already worked, which makes the failure easy to miss. After await process_frame,
every check passed: the hitbox was open at 0.10 s and 0.15 s and closed at 0.05 s, 0.20 s and 0.30 s, and one large
advance(0.15) made the call keyed at 0.10 s like nine small ones. A seek from 0 s to 0.25 s made only the call keyed
at 0.20 s, the last one before its new time, and skipped the one at 0.10 s; the AnimationPlayer reference warns that
events in between are skipped. Ending matters too: seek() to the very end stopped the player without
animation_finished, while advance() past the end emitted it once. So a test that needs every call on the way, or
the signal at the end, should advance.
Whole scenes are tested the same way at a fixed step: godot --headless --fixed-fps 60 gives every frame 1/60 s of
game time, so a recorded attack lands on the same ticks on every run, as the
physics ticks lesson showed with
--fixed-fps: A command-line option that gives every frame the same length of game time and switches off real-time synchronization, so a headless run computes 120 ticks in a few milliseconds instead of two seconds, with the same result. More in “Physics ticks, interpolation and jitter”.
Godot 4.6 and 4.7 in the animation editor
Godot 4.6 lets you resize an animation by dragging its length on the timeline, and adds visibility, lock and delete buttons for node groups in the Bezier track editor. In Godot 4.7 the editor’s tracks can be collapsed by node, with a summary of the collapsed node’s keys in its row.
Checkpoint
Check: Testing an AnimationPlayer by hand
Practise: an attack that a test can trust
Finish attack_kit.gd. Its add_attack() adds the hero’s attack to an AnimationPlayer: the hitbox opens at 0.10 s and
closes at 0.20 s, the Swoosh player plays a swoosh at 0.10 s, and a RESET animation closes the hitbox. The hero’s code
reads the hitbox in the same frame. As given, the attack never closes the hitbox, has no sound and no RESET, and leaves
the calls deferred.
- In a Godot 4.7 project, save
hero.gd,attack_kit.gdandcheck_attack.gdfrom below. - Add the close key, an audio track on Swoosh, a RESET animation in the global library that keys
.:hitbox_opentofalse, and make the method calls immediate. - In a terminal in the project’s folder, run
godot --headless --no-header --script check_attack.gd.
extends RefCounted
# Adds the hero's attack to an AnimationPlayer whose root node is the hero (hero.gd):
# "attack", 0.40 s: the hitbox opens at 0.10 s and closes at 0.20 s, and the hero's Swoosh player plays swoosh at
# 0.10 s;
# "RESET": the hitbox closed, so an attack that is interrupted can be put back.
# The hero's code reads hitbox_open in the same frame as the animation moves, so the calls must be made at once.
static func add_attack(player: AnimationPlayer, swoosh: AudioStream) -> void:
var attack := Animation.new()
attack.length = 0.4
var calls := attack.add_track(Animation.TYPE_METHOD)
attack.track_set_path(calls, ".")
attack.track_insert_key(calls, 0.10, {"method": &"open_hitbox", "args": []})
var library := AnimationLibrary.new()
library.add_animation(&"attack", attack)
player.add_animation_library(&"", library) extends SceneTree
# Checks attack_kit.gd of the practice. Run it in the project's folder:
# godot --headless --no-header --script check_attack.gd
# Each check builds a new hero with hero.gd, a Swoosh player and an AnimationPlayer driven by hand, adds the attack
# with AttackKit.add_attack() and reads the hero right after the ticks it names (1/60 s each).
const AttackKit := preload("res://attack_kit.gd")
const TICK := 1.0 / 60.0
var right := 0
var total := 0
func _initialize() -> void:
await process_frame # method tracks call nothing until the nodes are in the tree
var hero := make_hero()
var attack: Animation = hero.get_node("AnimationPlayer").get_animation(&"attack") if hero.get_node("AnimationPlayer").has_animation(&"attack") else null
check("attack lasts 0.40 s and does not loop", attack != null and is_equal_approx(attack.length, 0.4) and attack.loop_mode == Animation.LOOP_NONE)
hero.free()
for case: Array in [[3, false], [6, true], [9, true], [12, false]]:
hero = make_hero()
play_ticks(hero, case[0])
check("after %d ticks (%.2f s) the hitbox is %s" % [case[0], case[0] * TICK, "open" if case[1] else "closed"], hero.hitbox_open == case[1])
hero.free()
hero = make_hero()
var swoosh: AudioStreamPlayer = hero.get_node("Swoosh")
play_ticks(hero, 3)
var quiet := not swoosh.playing
play_ticks(hero, 3, false)
check("Swoosh is quiet at 0.05 s and plays at 0.10 s", quiet and swoosh.playing)
hero.free()
hero = make_hero()
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
play_ticks(hero, 9)
if player.has_animation(&"RESET"):
player.play(&"RESET")
player.advance(0.0)
await process_frame
check("RESET closes an attack interrupted at 0.15 s, still after the frame ends", not hero.hitbox_open and player.has_animation(&"RESET"))
hero.free()
hero = make_hero()
play_ticks(hero, 18)
var closed_now: bool = not hero.hitbox_open
await process_frame
check("at 0.30 s the hitbox is closed, still after the frame ends", closed_now and not hero.hitbox_open)
hero.free()
await create_timer(0.25).timeout # gives the audio server time to release the freed players' sounds
print("%d of %d checks right" % [right, total])
quit(0 if right == total else 1)
func play_ticks(hero: Node2D, ticks: int, start := true) -> void:
var player: AnimationPlayer = hero.get_node("AnimationPlayer")
if start:
player.play(&"attack")
for i in ticks:
player.advance(TICK)
func check(what: String, ok: bool) -> void:
total += 1
if ok:
right += 1
else:
print("WRONG: " + what)
func make_hero() -> Node2D:
var hero := Node2D.new()
hero.set_script(load("res://hero.gd"))
var swoosh := AudioStreamPlayer.new()
swoosh.name = "Swoosh"
hero.add_child(swoosh)
var player := AnimationPlayer.new()
player.name = "AnimationPlayer"
player.callback_mode_process = AnimationMixer.ANIMATION_CALLBACK_MODE_PROCESS_MANUAL
hero.add_child(player)
AttackKit.add_attack(player, silence(0.1))
root.add_child(hero)
return hero
# A silent sound, so the check needs no audio file.
func silence(seconds: float) -> AudioStreamWAV:
var wav := AudioStreamWAV.new()
wav.format = AudioStreamWAV.FORMAT_16_BITS
wav.mix_rate = 22050
var data := PackedByteArray()
data.resize(int(22050 * seconds) * 2)
wav.data = data
return wav The checker runs eight checks. Below, it grades three kits: the one given above, a finished kit, and a tempting kit with every key, the sound and RESET, whose method calls are still deferred:
from session import files, run
# The checker on attack_kit.gd as it was given, on a finished one, and on a tempting one that has every key, the
# sound and RESET, but leaves the method calls deferred.
for folder, kit in (("given", "practice/attack_kit.gd"), ("right", "practice/solution/attack_kit.gd"),
("tempting", "practice/tempting/attack_kit.gd")):
files(f"practice/project.godot={folder}/project.godot", f"practice/hero.gd={folder}/hero.gd",
f"practice/check_attack.gd={folder}/check_attack.gd", f"{kit}={folder}/attack_kit.gd")
run("""
cd given
!godot --headless --no-header --script check_attack.gd
cd ../right
godot --headless --no-header --script check_attack.gd
cd ../tempting
!godot --headless --no-header --script check_attack.gd
""") Output
$ cd given $ godot --headless --no-header --script check_attack.gd WRONG: after 6 ticks (0.10 s) the hitbox is open WRONG: after 9 ticks (0.15 s) the hitbox is open WRONG: Swoosh is quiet at 0.05 s and plays at 0.10 s WRONG: RESET closes an attack interrupted at 0.15 s, still after the frame ends WRONG: at 0.30 s the hitbox is closed, still after the frame ends 3 of 8 checks right [exit status 1] $ cd ../right $ godot --headless --no-header --script check_attack.gd 8 of 8 checks right $ cd ../tempting $ godot --headless --no-header --script check_attack.gd WRONG: after 6 ticks (0.10 s) the hitbox is open WRONG: after 9 ticks (0.15 s) the hitbox is open WRONG: RESET closes an attack interrupted at 0.15 s, still after the frame ends 5 of 8 checks right [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The given kit got 3 of 8: its hitbox was never seen open, and after the frame ended its open call left it open for good. The tempting kit got 5 of 8. Its deferred open call waited for the end of the frame, after RESET had already closed the hitbox, and opened it again. The finished kit got all 8. Next comes the self-check, one question at a time, on whichever of the six ideas your answers show you know least:
Practice that adapts to you
Practise this lesson
14 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what is the RESET animation of an AnimationPlayer for?
RESET is an animation with that exact name in the player’s global library, with one key at 0 s for each property the
other animations change, at its rest value. The editor uses it twice: it offers to create it when you key a property,
and with reset_on_save, on by default, it saves the scene with RESET’s values while showing the pose you were
editing, so a scene is never saved halfway through a swing. At run time it is the way back to rest: playing RESET and
calling advance(0.0) put an interrupted attack’s hitbox, sword and scale back at once in a Godot 4.7.2 run.
Deterministic blending, the AnimationTree’s default, starts every frame from RESET’s values, and without a RESET it
starts from 0: the hero’s scale fell to 0.00001.
Key takeaways
- An animation is a set of tracks with keys; at the playhead each track gives a value, Linear at a steady rate, Cubic eased, and Nearest holding each key until the next one, and discrete tracks change only at their keys.
- A track path starts at the player’s root node, its parent by default; a path that leads nowhere prints a warning, and a path that leads to the wrong node, such as the window, prints nothing.
- Value, method, audio and the other track types each change the scene in their own way, and an audio track takes over the stream of the player it names, so it needs a player of its own.
- Method keys are events, made at the end of the frame by default, so code or tests that read their effect in the same frame need immediate mode.
- A property keeps the value the last animation gave it until something sets it again, so an interrupted animation needs RESET, played or used by deterministic blending, which without RESET starts from 0.
- Tests set the player to manual processing, wait one frame for the nodes to join the tree, and use
advance()to reach exact times and the end of an animation.
Terms in this lesson
- animation library
- An AnimationLibrary resource that groups animations. The global library has an empty name; an animation called attack in a library called combat is played as combat/attack.
- deterministic blending
- AnimationMixer.deterministic: every frame starts from the RESET animation's values, or from 0 without one, and adds what the playing animations key. Off by default on AnimationPlayer, on for AnimationTree.
- interpolation (of a track)
- How a value track works out the values between its keys: Linear at a steady rate, Cubic slower near the keys and faster between them, Nearest holding each key's value until the next key.
- key (keyframe)
- A value, a call or a sound stored at one time on an animation track. A value track fills the time between two keys by its interpolation; a method or audio key acts when the playhead passes it.
- RESET animation
- An animation named RESET in the global library, with one key at 0 s for each animated property at its rest value. The editor saves scenes with its values, and deterministic blending starts from them.
- root node (of an AnimationPlayer)
- AnimationMixer.root_node, the node from which every track path is read. It is ".." by default, the player's parent, so "." in a path is the parent and Sword is the parent's child Sword.
- track (animation)
- One line of an Animation: a path to a node and a property, a method, a sound or another player, with keys at times. At the playhead each track gives its value or makes its call.
References
- Introduction to the animation features (Godot Engine)
- Animation track types (Godot Engine)
- AnimationPlayer (class reference) (Godot Engine)
- AnimationMixer (class reference) (Godot Engine)
- Animation (class reference) (Godot Engine)
- AnimationLibrary (class reference) (Godot Engine)
- Godot 4.6 release page (animation editor) (Godot Engine)
- Godot 4.7 release page (animation editor) (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress