Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free: 20 minutes

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.

slide_anim.gd: an animation built in code and read nine times GD · slide_anim.gd
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()
Three interpolations and two update modes, Godot 4.7.2 Python · slide_session.py
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:

Three lines through keys 0, 10 and 4: Linear is straight, Cubic eases in and out, Nearest holds each key until the next one.One track, three interpolationsKeys: 0 at 0.0 s, 10 at 0.5 s, 4 at 1.0 s05100 s0.25 s0.5 s0.75 s1 sposition:xat 0.25 s: 0, 5.0, 4.75NearestLinearCubicMarks: the values the run printed every 0.125 s

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.

  1. 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.
  2. 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.
  3. 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.

CSS Animation & Keyframes Generator Build a three-keyframe animation and switch its timing between linear and eased to see the in-between values change.

Checkpoint

Check: Tracks, keys and interpolation

  1. Question 1 of 2 A value track with linear interpolation is keyed 0 at 0.0 s and 10 at 0.5 s. What value does it give at 0.2 s?

    Type a number.

    A hint

    Find the share of the way from the first key to the second.

    Show the answer: Question 1 of 2

    Answer: 4

    0.2 s is 0.2 / 0.5 = 40 % of the way between the keys, and 40 % of 10 is 4, as the run's linear track gave 2.5 at 0.125 s, a quarter of the way.

  2. Question 2 of 2 A value track with Nearest interpolation is keyed 0 at 0.0 s and 10 at 0.5 s. What did Godot 4.7.2 give at 0.4 s?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 0, because Nearest holds each key until the next one

    In this run Nearest acted as a step: 0 from 0.0 s, 10 from 0.5 s, 4 from 1.0 s.

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:

paths.gd: five track paths from one AnimationPlayer GD · paths.gd
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)
Where track paths lead, Godot 4.7.2 Python · paths_session.py
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:

Interactive · step by step Where five track paths lead from one AnimationPlayer Step through the tracks of paths.gd. Each path is read from the player's root node, its parent Hero.
Where five track paths lead from one AnimationPlayer, step 6 of 6. Showing root (the window) (moved to x = 5), Hero (the scene's root), AnimationPlayer, Sword (x = 5.0). In focus: root (the window).root (the window)moved to x = 5Herothe scene's rootAnimationPlayerroot_node is ".."Swordx = 5.0

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. ".: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.
  3. "Sword:position:x". A name goes down from the root node to a child of that name. Sword's x was 5.0 too.
  4. "..: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.
  5. 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.
  6. 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

  1. Question 1 of 2 An AnimationPlayer is a child of Enemy, next to a Sprite2D called Sprite. root_node is left at "..". Which node does a track with the path Sprite:modulate change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Enemy's child Sprite, because the path starts at the player's parent

    Sprite:modulate means the child Sprite of the root node, then its modulate property.

  2. Question 2 of 2 Which of these track path mistakes did Godot 4.7.2 animate without printing any warning?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A path that leads to another node, such as ..:position:x reaching the window

    The warning catches paths that lead nowhere. Paths that lead to the wrong node need a test that checks which node moved.

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:

hero.gd: the hero of this page, with a hitbox and a list of calls GD · hero.gd
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)
attack.gd: a method track and an audio track, played tick by tick GD · attack.gd
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
Method calls in two modes, and an audio track, Godot 4.7.2 Python · attack_session.py
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

  1. Question 1 of 2 A run animation must spawn a dust puff each time a foot lands, at 0.15 s and 0.45 s. Which track does that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A method call track with spawn_dust() keyed at 0.15 s and 0.45 s

    Method tracks are the animation's events: hitboxes, footsteps, spawns.

  2. Question 2 of 2 Voice held the hero's shout. After an attack whose audio track names Voice, $Voice.play() plays nothing. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The audio track replaced Voice's stream while it played and left no stream when the attack ended

    Give every audio track a player of its own, and keep the players your code uses out of animations.

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:

Interactive · stage by stage What one advance() call does when it passes a method key Step through tick 6 of attack.gd, when the playhead reaches open_hitbox() at 0.10 s, first in deferred mode, then in immediate mode.
What one advance() call does when it passes a method key, step 6 of 6. Showing The playhead moves, Value tracks are applied, Method keys passed (called inside advance()), Your code reads (true after ticks 6 to 11), The frame ends (three calls at once). In focus: Method keys passed, Your code reads.deferred: queuedThe playhead moves0.083 s to 0.100 sValue tracks are appliedproperties set at onceMethod keys passedcalled inside advance()Your code readstrue after ticks 6 to 11The frame endsthree calls at once

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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().
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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

  1. Question 1 of 2 callback_mode_method is left at its default. A test plays the attack by hand, advances 9 ticks (0.15 s) and reads hitbox_open in the same function. The hitbox opens at a key at 0.10 s. What does it read?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: false, because the open call waits for the end of the frame

    Set callback_mode_method to IMMEDIATE wherever code reads a method key's effect in the same frame.

  2. Question 2 of 2 In immediate mode, a hitbox opens at a key at 0.10 s and closes at a key at 0.20 s. The player is advanced in ticks of 1/60 s and read after each tick. After how many ticks does it read open?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 6 ticks

    It opens on the tick that reaches 0.10 s, tick 6, and closes on the tick that reaches 0.20 s, tick 12, so it reads open after ticks 6 to 11: six ticks, as the run printed.

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:

Interactive · message by message An attack interrupted at 0.15 s, and put back with RESET Step through the hit. Arrows are the calls your code makes and the values the player sets on the hero.
An attack interrupted at 0.15 s, and put back with RESET, step 6 of 6. Messages so far: Your code to AnimationPlayer: play combat/attack; AnimationPlayer to Hero: open_hitbox(); AnimationPlayer to Hero: sword 67.5°, squash; Your code to AnimationPlayer: play combat/hurt; AnimationPlayer to Hero: modulate red to white; Your code to AnimationPlayer: play RESET, advance(0).Your codereacts to hitsAnimationPlayerHeroclosed, 0°, scale (1, 1)play combat/attackopen_hitbox()sword 67.5°, squashplay combat/hurtmodulate red to whiteplay RESET, advance(0)

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The attack starts. The hero attacks. Your code plays the combat library's attack, 0.4 s long.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. 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:

interrupt.gd: an attack cut short by a hit, five ways GD · interrupt.gd
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])
What an interrupted attack leaves behind, Godot 4.7.2 Python · interrupt_session.py
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

  1. Question 1 of 2 A default AnimationPlayer plays an attack that has turned the sword to 67.5° by 0.15 s. Then it plays hurt, which keys only the hero's modulate. Where is the sword once hurt has finished?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Still at 67.5°

    A property keeps the value the last animation gave it until something sets it again.

  2. Question 2 of 2 A team turns on deterministic = true for an AnimationPlayer that has no RESET animation. From then on the hero disappears whenever hurt plays. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Each frame starts from 0 for every animated property, and hurt does not key scale, so scale fell to about 0

    Deterministic blending needs a RESET in the global library with every animated property at rest.

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:

test_attack.gd: the attack checked at exact times GD · test_attack.gd
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
Nine checks of an attack, Godot 4.7.2 Python · test_session.py
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

  1. Question 1 of 2 Which setting stops an AnimationPlayer from moving by itself, so that a test moves it with advance()?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: callback_mode_process = ANIMATION_CALLBACK_MODE_PROCESS_MANUAL

    In manual mode only advance() moves the playhead, so a test decides every time it reads.

  2. Question 2 of 2 A test calls seek(attack.length, true) and then waits for animation_finished. What happens?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The signal never comes, because seek() to the very end stops the player without emitting it

    A test that needs animation_finished should advance() past the end, as the docs advise.

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.

  1. In a Godot 4.7 project, save hero.gd, attack_kit.gd and check_attack.gd from below.
  2. Add the close key, an audio track on Swoosh, a RESET animation in the global library that keys .:hitbox_open to false, and make the method calls immediate.
  3. In a terminal in the project’s folder, run godot --headless --no-header --script check_attack.gd.
attack_kit.gd: the attack as given GD · practice/attack_kit.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)
check_attack.gd: the checker of this task GD · practice/check_attack.gd
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:

The checker on three attack kits, Godot 4.7.2 Python · check_session.py
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.

  1. Question 1 of 14 What does one value track of an Animation hold?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: A path to a node and property, and keys, each a time and a value

    Sword:rotation_degrees with keys at 0.0, 0.2 and 0.4 s is one track.

  2. Question 2 of 14 In the run, the cubic track gave 1.469 at 0.125 s and 8.156 at 0.375 s, against 2.5 and 7.5 for the linear one. What does that say about cubic interpolation?

    Choose one answer.

    Show the answer: Question 2 of 14

    Answer: It is slower near the keys and faster between them

    Cubic eases out of one key and into the next, which suits characters.

  3. Question 3 of 14 An animation cuts a sprite from frame 0 to frame 3 with keys at 0.0 s and 0.6 s, and players see frames 1 and 2 flash past. What fixes it?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: Set the frame track to UPDATE_DISCRETE, so the frame changes only at its keys

    Discrete tracks set the value only when the playhead reaches a key.

  4. Question 4 of 14 What is an AnimationPlayer's root_node by default?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: "..", the player's parent

    So "." in a track path is the parent, and a name is one of the parent's children.

  5. Question 5 of 14 Hero's scene is run on its own, with the player under Hero and root_node at its default. Where does a track with the path ..:position:x lead?

    Choose one answer.

    Show the answer: Question 5 of 14

    Answer: To the window, because Hero's parent is the root window

    Keep paths inside the scene, from the root node down.

  6. Question 6 of 14 Which of these are track types of a Godot 4.7 Animation?

    Choose every answer that is right.

    Show the answer: Question 6 of 14

    Answer:

    • Method call
    • Audio playback
    • Animation playback

    The nine types are value, position 3D, rotation 3D, scale 3D, blend shape, method, bezier, audio and animation.

  7. Question 7 of 14 A designer keys a sword's rotation with a bezier curve track in one animation and with a value track in another. What does the track types page warn about this?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Bezier tracks and value tracks of a property cannot be blended

    Choose one kind of track for a property across the animations that blend.

  8. Question 8 of 14 Why is callback_mode_method deferred by default?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: It avoids bugs when a called method deletes a node or changes the player while it plays

    A death animation's queue_free() is safe deferred; a hitbox read in the same frame needs immediate.

  9. Question 9 of 14 In deferred mode, 24 ticks of the attack were advanced in one frame. When the frame ended, which calls ran?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: open_hitbox(), close_hitbox() and footstep("left"), together, in key order

    The hitbox opened and closed in one moment, so no code ever saw it open.

  10. Question 10 of 14 The animation attack is in a library called combat. What name does play() take?

    Choose one answer.

    Show the answer: Question 10 of 14

    Answer: combat/attack

    The global library's animations, such as RESET, have no prefix.

  11. Question 11 of 14 You save a scene in the editor while previewing the middle of a swing. reset_on_save is on and RESET keys the sword at 0°. What does the saved file hold for the sword?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: 0°, RESET's value, while the editor keeps showing the swing

    RESET keeps scenes from being saved halfway through an animation.

  12. Question 12 of 14 A team moves every animation of a character, RESET included, into a library called hero and keeps deterministic = true. Halfway through hurt, the run's equivalent showed a scale of 0.00001. Why?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: The animation is now hero/RESET, which the player does not use as RESET, so blending starts from 0

    RESET belongs in the global library, under that exact name.

  13. Question 13 of 14 A test script that extends SceneTree builds the hero in _initialize(), plays the attack and advances it 0.15 s at once. hitbox_open stays false even in immediate mode. What is missing?

    Choose one answer.

    Show the answer: Question 13 of 14

    Answer: An await process_frame first, because method tracks call nothing until the nodes are in the tree

    Value tracks work before the first frame, which is why this trap hides well.

  14. Question 14 of 14 A test jumps an attack from 0 s to 0.25 s with seek(0.25, true). open_hitbox() is keyed at 0.10 s and close_hitbox() at 0.20 s. Which calls did Godot 4.7.2 make?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Only close_hitbox(), the last key before 0.25 s

    A seek jumps and does not replay: the run made only the last call before the new time. To make every call on the way, as a frame of the game does, a test uses advance().

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.