Skip to content

M2.2 — Sprites & Animation

What you'll learn

  • Choose between AnimatedSprite2D (flipbook) and AnimationPlayer (tracks), and set up SpriteFrames.
  • Drive the animation from velocity so 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 AnimatedSprite2D and AnimationPlayer. 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

  • AnimatedSprite2D plays a flipbook of frames stored in a SpriteFrames resource. 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 the run animation."
  • AnimationPlayer animates 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 single Sprite2D'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

  1. Open res://scenes/actor/player.tscn. Delete the placeholder Sprite2D from M2.1.
  2. Add a child of Player: AnimatedSprite2D, renamed Sprite.
  3. With Sprite selected, in the Inspector click the Sprite Frames propertyNew SpriteFrames. Click it to open the SpriteFrames bottom panel.
  4. In the panel, rename the default default animation to idle. Add frames (drag your idle frames in, or use placeholder textures). Set its FPS low (e.g., 5) and ensure Loop is on.
  5. Add a new animation run. Add its frames, set a higher FPS (e.g., 10), Loop on.
  6. Back in player.gd, add the @onready var sprite line and the _update_animation method, and call it at the end of _physics_process as shown. Type it; do not paste a finished file.
  7. Press F5. Standing still plays idle; walking plays run; 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.