M2.2 — Sprites & Animation¶
What you'll learn
- Choose between
AnimatedSprite2D(flipbook) andAnimationPlayer(tracks), and set upSpriteFrames. - Drive the animation from
velocityso the visuals follow motion automatically. - Face the character with
flip_h, kept decoupled from the collision body.
How it applies
- Readability of state. A player must instantly see whether their character is moving, attacking, or hurt. Animation is the channel that communicates actor state at a glance; in a fast genre, a character stuck on its idle frame while sliding looks broken even when the code is correct.
- Visuals follow simulation, not the reverse. Driving animation from
velocity(which the M2.1 movement code already produces) means there is one source of truth for "is the player moving." Two sources — one for physics, one for art — drift, and the drift is a class of bug ("animation says idle, body is moving") that is tedious to track down. - Art pipeline reality. Whether your art is a sprite sheet or individual frames decides between
AnimatedSprite2DandAnimationPlayer. Choosing the right node up front avoids re-authoring every animation later. - The sprite is not the hitbox. Keeping the visual separate from the collision shape (M2.1) and the hurtbox (M3) means you can tune how the character looks without changing how it collides — a separation testers rely on when a "hit didn't register" bug turns out to be art, not physics.
Concepts¶
Two animation nodes¶
AnimatedSprite2Dplays a flipbook of frames stored in aSpriteFramesresource. Each named animation (idle,run,attack) is a list of frames with an FPS. Ideal for hand-drawn or pixel-art characters delivered as frames or a sprite sheet. Simple mental model: "play therunanimation."AnimationPlayeranimates any property of any node over keyframed tracks — position, modulate, a shader parameter, even calling functions at a time. More powerful and more work. Use it for complex, multi-property sequences (a boss telegraph that scales, tints, and shakes), or to drive a singleSprite2D's region for sheet-based animation when you want track-level control.
For a top-down character with idle/run/attack flipbooks, AnimatedSprite2D is the right default.
This book uses it for actors and reserves AnimationPlayer for effects that animate more than a frame
index. (You can use both on one actor: AnimatedSprite2D for the character's frames, AnimationPlayer
for a hit-flash, which M3.4 does.)
SpriteFrames¶
SpriteFrames is the resource an AnimatedSprite2D reads. In the editor's SpriteFrames bottom
panel you create animations by name, add frames (drag from a sheet or add individual textures), set the
FPS per animation, and toggle looping. idle loops slowly; run loops faster; attack (M3) does not
loop and signals when done.
Driving animation from velocity¶
The character's animation should follow what it is doing, and M2.1 already computes that: velocity.
The rule is simple — if the body is moving, play run; otherwise idle. Because velocity is a
floating-point vector, compare its length against a small threshold rather than exact zero, to avoid
flickering when it is nearly (but not exactly) stopped.
Example
Add an AnimatedSprite2D named Sprite as a child of Player, then extend player.gd to pick the
animation each physics step, after the movement lines from M2.1:
@onready var sprite: AnimatedSprite2D = $Sprite
func _physics_process(_delta: float) -> void:
var direction := Input.get_vector("move_left", "move_right", "move_up", "move_down")
velocity = direction * speed
move_and_slide()
_update_animation(direction)
func _update_animation(direction: Vector2) -> void:
if direction.length() > 0.1:
sprite.play("run")
else:
sprite.play("idle")
@onready var sprite := $Sprite caches the child once the node is in the tree; $Sprite is shorthand
for "the child named Sprite". play() is idempotent — calling play("run") while run is already
playing does nothing, so calling it every frame is fine.
Facing¶
For a side-flippable character, mirror the sprite horizontally based on the horizontal input. flip_h
on AnimatedSprite2D mirrors the frames without separate left/right art:
Example
Extend _update_animation to face the way the player moves horizontally. Only flip when there is
horizontal input, so the character keeps its last facing when moving straight up or down:
func _update_animation(direction: Vector2) -> void:
if direction.length() > 0.1:
sprite.play("run")
if direction.x != 0.0:
sprite.flip_h = direction.x < 0.0 # face left when moving left
else:
sprite.play("idle")
flip_h = direction.x < 0.0 sets the flag to true (mirror) when moving left, false when moving
right. Guarding on direction.x != 0.0 preserves facing during pure vertical movement.
A top-down game with art for each of four (or eight) directions would instead pick a directional
animation (run_up, run_down, run_side) from the dominant axis of direction, and flip the side
animation for left/right. The flip_h approach is the cheapest that reads correctly; choose based on
your art.
The sprite is independent of the body¶
The AnimatedSprite2D is a child of the CharacterBody2D for position, but it has no role in collision.
The collision shape (M2.1) defines the footprint; the sprite defines the look; the hurtbox (M3) defines
the damageable region. Three separate concerns on three separate children. You can scale or offset the
sprite to look right without moving the collision shape — and you should expect them to differ (art
usually overhangs the physical footprint).
Walkthrough¶
- Open
res://scenes/actor/player.tscn. Delete the placeholderSprite2Dfrom M2.1. - Add a child of
Player:AnimatedSprite2D, renamedSprite. - With
Spriteselected, in the Inspector click the Sprite Frames property → New SpriteFrames. Click it to open the SpriteFrames bottom panel. - In the panel, rename the default
defaultanimation toidle. Add frames (drag your idle frames in, or use placeholder textures). Set its FPS low (e.g., 5) and ensure Loop is on. - Add a new animation
run. Add its frames, set a higher FPS (e.g., 10), Loop on. - Back in
player.gd, add the@onready var spriteline and the_update_animationmethod, and call it at the end of_physics_processas shown. Type it; do not paste a finished file. - Press
F5. Standing still playsidle; walking playsrun; moving left mirrors the sprite. The animation now reflects the movement with no separate state tracking.
Optional sanity check
Temporarily lower the threshold in _update_animation from 0.1 to 0.0 and tap a movement key
very briefly: you may see the run/idle animation flicker as velocity settles near zero. Restore
0.1. This is why the comparison uses a small threshold instead of == Vector2.ZERO: floating-point
velocity rarely lands on exact zero, and exact comparisons flicker.
Self-check quiz¶
Q1 — For a top-down character with idle/run/attack flipbooks, which node is the right default and why?
A. AnimationPlayer, because it is more powerful.
B. AnimatedSprite2D, because it plays named frame animations from a SpriteFrames resource, which
is exactly the flipbook model these animations are.
C. Sprite2D, because it supports flip_h.
D. AnimationTree, because all character animation requires it.
Reveal answer
B. The animations are flipbooks, which is precisely what AnimatedSprite2D + SpriteFrames
models with the least friction. A is true but irrelevant — extra power you don't need is extra
work. C: Sprite2D also has flip_h but plays no animation. D overstates AnimationTree, which
is for blending/state-graphing animations, useful later but not required here.
Q2 — Why drive the animation choice from velocity (or the input direction) rather than setting it in the same place you read the keys, as a separate flag?
A. It is faster.
B. It keeps one source of truth for 'is the player moving,' so the art can't disagree with the
physics; two independent sources drift.
C. velocity is the only value play() accepts.
D. The Inspector requires it.
Reveal answer
B. Movement state already exists as velocity; deriving animation from it means there is a
single fact about motion that both physics and art read. A separate animation flag is a second
fact that can fall out of sync ("idle animation while sliding"). A is not the reason. C is false
(play takes an animation name). D is fabricated.
Q3 — Why is if direction.length() > 0.1 preferred over if direction != Vector2.ZERO for the moving check?
A. Vector2.ZERO does not exist.
B. Floating-point input/velocity rarely lands on exact zero, so an exact comparison flickers between
run and idle near standstill; a small threshold is stable.
C. length() is required before calling play().
D. != is not defined for Vector2.
Reveal answer
B. Near standstill, residual tiny values make != Vector2.ZERO true intermittently, toggling
the animation. A threshold treats "almost stopped" as stopped. A is false. C is fabricated. D is
false (Vector2 supports ==/!=, it's just fragile here for floats).
Integration question¶
Q4 — open
The AnimatedSprite2D, the CollisionShape2D (M2.1), and the Hurtbox you will add in M3 are three
children of the same Player body. Explain what each is responsible for, why keeping them separate is
deliberate rather than incidental, and give one concrete bug that the separation makes easy to
diagnose.
Reveal expected answer
The AnimatedSprite2D is responsible for how the character looks (frames, facing); the
CollisionShape2D for the character's physical footprint against the world (what walls stop);
the Hurtbox (M3) for the damageable region (what enemy attacks register against). Keeping them
separate is deliberate because the three rarely coincide — art overhangs the footprint, and a
fair hurtbox is often smaller than the sprite so near-misses don't count. The separation makes a
whole class of bug diagnosable: if a hit "looks like it connected but dealt no damage," you can
check whether the hurtbox overlapped (a physics/sizing question) independently of where the
sprite was drawn (an art question). One source of truth per concern means each can be inspected
and fixed without disturbing the others — the same single-source-of-truth principle that drives
animation from velocity.