Skip to content

G2.3 — Visual 2D Nodes

What you'll learn

  • Show images with Sprite2D and play flipbook animations with AnimatedSprite2D + SpriteFrames.
  • Frame the view with Camera2D (make_current), and follow a target by parenting.
  • Pin UI to the screen with CanvasLayer, immune to the world camera.
  • Control draw order with tree order, z_index, and y-sorting.

How it applies

  • UI under the world scrolls and zooms with the camera. A health bar parented into world space slides and shrinks as the camera moves — it must sit under a CanvasLayer to stay pinned. This is the most common "my HUD is drifting" bug.
  • No current camera, or the wrong one, frames the wrong thing. With several Camera2D nodes, only the current one is used; forgetting to make a camera current, or making the wrong one current, shows the player the wrong region — a black screen or a fixed view that should have followed.
  • Draw order decides what is visible. Sprites draw in tree order (and by z_index); get it wrong and the character hides behind the background, or a top-down sprite that should be "behind" a wall draws in front. Ordering is a correctness concern, not just polish.
  • The wrong node for the job fights you. A static decoration does not need AnimatedSprite2D; a pinned HUD does not belong on a world Node2D. Choosing the right visual node keeps behavior predictable.

Concepts

Node2D as a grouping/transform node

Node2D itself draws nothing — it is a position/rotation/scale in the world (G2.2) and a parent for things that do draw. You use it to group a unit (player + sprite + collision + camera) so the whole group transforms together. When you want "a thing in the world that has parts," the parts hang under a Node2D.

Sprite2D

Sprite2D displays a single texture:

@onready var sprite: Sprite2D = $Sprite

func face(dir: int) -> void:
    sprite.flip_h = dir < 0      # mirror horizontally to face left
    sprite.modulate = Color(1, 0.5, 0.5)   # tint (a Color, 0–1 channels, L1.6)

Key properties: texture (the image), centered (whether the origin is the texture's center or top-left), flip_h / flip_v (mirroring), and modulate (a color multiply, useful for tints and fade — alpha included). Setting the texture in the Inspector is the common path; the script tweaks it.

AnimatedSprite2D and SpriteFrames

For frame-by-frame animation, AnimatedSprite2D plays named animations defined in a SpriteFrames resource (you build the frame lists in the editor's SpriteFrames panel):

@onready var anim: AnimatedSprite2D = $Anim

func _ready() -> void:
    anim.play("idle")

func start_running() -> void:
    anim.play("run")

Each named animation ("idle", "run") is a list of frames with an FPS and a loop flag. AnimatedSprite2D emits animation_finished when a non-looping animation completes — a signal you wire in G2.6 (and that the game books use to time attacks). Use a plain Sprite2D for static art; reach for AnimatedSprite2D only when there are frames to flip through.

Camera2D

A Camera2D defines what region of the world is shown. Add one to the scene and make it active:

func _ready() -> void:
    make_current()      # this Camera2D becomes the view

The simplest follow is to make the camera a child of the thing it follows — parented to the player Node2D, it inherits the player's transform and tracks automatically (G2.2's compounding). Useful properties: zoom (a Vector2; larger zooms in), position smoothing, and limits that keep the view inside the level bounds. With multiple cameras, exactly one is current; make_current() (or the Current checkbox) selects it.

CanvasLayer — pinning UI

UI must not move with the world camera, so it goes under a CanvasLayer, which renders its children on an independent layer with its own transform — unaffected by Camera2D:

Main
├── Player (Node2D)
│   └── Camera2D
└── HUD (CanvasLayer)
    └── HealthBar (Control)

Because HUD is a CanvasLayer, its Control children stay fixed on screen while the player and camera move through the world. This is the structural fix for the "HUD scrolls with the world" bug from G1.2: world objects under Node2D, screen UI under CanvasLayer.

Draw order: tree order, z_index, y-sort

Within a layer, nodes draw in tree order — later siblings draw on top of earlier ones. So a background should come before the things drawn over it. z_index overrides this for fine control (higher draws later/on top). For top-down games where lower-on-screen objects should appear in front, y-sorting (enabling Y-Sort on a parent) orders children by their y position automatically. Keep it simple: order siblings correctly first, reach for z_index/y-sort when tree order is not enough.

Example

The canonical 2D scene skeleton, with each node's job:

Player (Node2D)            # a unit in the world
├── Sprite (Sprite2D)      # its art
└── Camera2D               # make_current() in _ready; follows by being a child
HUD (CanvasLayer)          # pinned UI layer, immune to the camera
└── ScoreLabel (Control)   # stays on screen as the player moves

The camera is a child of the player, so it follows for free; the HUD is a CanvasLayer, so it does not. That split — followed world versus pinned screen — is the whole reason both node types exist.

Walkthrough

  1. Build a Node2D Player with a Sprite2D child (any texture). Add a Camera2D child to Player and call make_current() in a script's _ready. Move Player in _physics_process and confirm the view follows.
  2. Add a CanvasLayer named HUD as a sibling of Player, with a Label child. Run and move the player: the label stays pinned while the world scrolls. Then, as an experiment, move the Label under Player instead and watch it drift with the world — then move it back.
  3. Tint and flip the sprite from code: set modulate to a color and toggle flip_h. Observe both.
  4. Draw order: add a second Sprite2D overlapping the first. Reorder the two in the Scene dock and watch which draws on top; then set the back one's z_index higher and watch it jump forward.

Optional sanity check

Temporarily remove the make_current() call (and ensure no camera is marked Current). Run and note the default framing — the camera is not driving the view. Restore make_current() and the view snaps to the camera. That is "exactly one current camera" demonstrated.

Self-check quiz

Q1 — Your health bar slides and shrinks as the player walks. What is the structural fix?

A. Set the health bar's z_index higher. B. Put the health bar's Control under a CanvasLayer, which renders independently of the world Camera2D. C. Make the camera a child of the health bar. D. Disable the camera.

Reveal answer

B. UI under a world Node2D is dragged by the camera; a CanvasLayer renders on its own layer, immune to the camera, keeping the bar pinned. A changes draw order, not whether it scrolls. C and D break the world view rather than fixing the UI placement.

Q2 — Two overlapping Sprite2D siblings: which draws on top, by default?

A. The one higher in the Scene dock (earlier sibling). B. The one lower in the Scene dock (later sibling), because siblings draw in tree order, later on top. C. Whichever has the larger texture. D. It is random each frame.

Reveal answer

B. Within a layer, later siblings draw over earlier ones, so the lower entry in the Scene dock draws on top by default; z_index can override this. A inverts the rule. C and D are not how draw order is decided.

Q3 — When should you use AnimatedSprite2D instead of Sprite2D?

A. Always; Sprite2D is deprecated. B. When you need to play frame-by-frame animations defined in a SpriteFrames resource; for static art, Sprite2D is the right, simpler choice. C. Only for UI. D. Never; use a script to swap textures manually.

Reveal answer

B. AnimatedSprite2D exists for flipbook animation via SpriteFrames; static images should use the simpler Sprite2D. A is false (Sprite2D is current and correct for stills). C is wrong — these are world sprites, not UI. D dismisses the purpose-built node for a manual hack.

Integration question

Q4 — open

Assemble a minimal playable view from this chapter and G2.1–G2.2: a world character that animates and is followed by the camera, plus a score label that stays on screen. Describe the node tree you would build, which node type each piece is and why, where make_current goes, and how draw order keeps the character visible above a background — and explain why the score label must not be a child of the character.

Reveal expected answer

Tree: a Player (Node2D — a world unit with a transform) holding an AnimatedSprite2D (frame animation via SpriteFrames, play("idle")/play("run")) and a Camera2D child; a sibling Background (Sprite2D or tilemap) placed earlier in the tree so it draws behind; and a HUD (CanvasLayer) holding a ScoreLabel (Control). Camera: make_current() in the Player/camera script's _ready, and because the Camera2D is a child of Player, it follows via transform compounding (G2.2) with no follow code. Draw order: the background is an earlier sibling (or lower z_index), so the character, drawn later, appears above it; if needed, z_index forces the character forward. Why the score label is not a child of the character: a Control under the world Player would inherit the world transform and the camera, so it would scroll, zoom, and move with the character instead of staying pinned on screen; placing it under a CanvasLayer renders it independently of the camera. The chapter's spine: world things are Node2D (followed by the camera), screen UI is Control under a CanvasLayer (pinned), and draw order is set by tree order with z_index/y-sort as the override.