Can you write this from memory?
Display the player's score on the HUD label. (Assume score is an integer variable and $ScoreLabel is a Label node.)
A good GDScript HUD updates instantly when game state changes, survives resolution changes, and handles player respawns cleanly. This page is your HUD cheat-sheet: signals for updates, anchors + containers for layout, and bind/unbind patterns for respawn safety.
Jump to: Layout | Bind/Unbind | Recipes | Pitfalls
# Player script: emits signal when health changes
signal health_changed(current: int, maximum: int)
func take_damage(amount: int) -> void:
hp = max(hp - amount, 0)
health_changed.emit(hp, max_hp)
# HUD script: listens and updates display
func _on_health_changed(current: int, maximum: int) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
hp_label.text = "%d / %d" % [current, maximum]
The HUD doesn't poll every frame. It updates only when something changes. See Timers & Signals for connection patterns.
Without CanvasLayer, your HUD moves when the camera moves:
Scene Tree:
├─ World (Node2D)
│ ├─ Player
│ ├─ Enemies
│ └─ Camera2D
└─ HUD (CanvasLayer)
└─ UI (Control) ← Full Rect anchor
└─ ... your UI nodes
Tip: Keep a Control node as the root of your UI (not the CanvasLayer itself). This lets you resize/anchor the UI from other scenes. Add the CanvasLayer in the main scene. If your HUD data comes from custom Resources, bind them to UI nodes for clean separation of data and display.
The second thing beginners struggle with after CanvasLayer: HUD breaks on different resolutions.
Anchors + Containers Structure
HUD (CanvasLayer)
└─ UI (Control) ← Anchor Preset: Full Rect
└─ Margin (MarginContainer)
└─ TopBar (HBoxContainer)
├─ HP (HBoxContainer)
│ ├─ HPBar (ProgressBar)
│ └─ HPLabel (Label)
└─ WaveTimer (Label)
Key principles:
- Control root → Full Rect: Set the anchor preset to "Full Rect" so it fills the screen
- Containers for layout: Use HBox/VBox/MarginContainer so spacing stays stable when text changes
- Anchors for positioning: Use corner anchors (Top Left, Bottom Right) to pin elements to screen edges
Stretch Settings (Project Settings)
Project → Settings → Display → Window:
- Stretch Mode: canvas_items (scales UI with resolution)
- Stretch Aspect: expand (fills screen, may letterbox)
In real projects, players get replaced (respawn, character select, multiplayer). Without proper unbinding:
- Duplicate signal connections
- HUD updating from a freed player
- Weird one-frame updates after queue_free()
extends CanvasLayer
@onready var hp_bar: ProgressBar = $UI/Margin/HPBar
@onready var hp_label: Label = $UI/Margin/HPLabel
var _player: Node
func bind_player(player: Node) -> void:
unbind_player() # Always unbind first
_player = player
_player.health_changed.connect(_on_health_changed)
_on_health_changed(_player.hp, _player.max_hp) # Initial sync
func unbind_player() -> void:
if _player and _player.health_changed.is_connected(_on_health_changed):
_player.health_changed.disconnect(_on_health_changed)
_player = null
func _on_health_changed(current: int, maximum: int) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
hp_label.text = "%d / %d" % [current, maximum]
Why unbind explicitly? Godot auto-disconnects when a node is freed, but queue_free() is deferred—signals can still fire until end-of-frame. Explicit unbind prevents edge cases. Understanding the script lifecycle (_ready vs _init) clarifies when to set up these connections.
HP Bar + Label
func _on_health_changed(current: int, maximum: int) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
hp_label.text = "%d / %d" % [current, maximum]
XP Bar + Level Label
func _on_xp_changed(xp: int, xp_to_next: int, level: int) -> void:
xp_bar.max_value = xp_to_next
xp_bar.value = xp
level_label.text = "Lv. %d" % level
Wave Countdown Timer
func format_time(seconds: int) -> String:
var mins := seconds / 60
var secs := seconds % 60
return "%d:%02d" % [mins, secs]
func _on_wave_time_changed(seconds_remaining: int) -> void:
timer_label.text = format_time(seconds_remaining)
Score Counter
func _on_score_changed(new_score: int) -> void:
score_label.text = "Score: %d" % new_score
Want animation? Tweens can punch the label or smooth the bar. Quick example:
func _on_score_changed(new_score: int) -> void:
score_label.text = "Score: %d" % new_score
# Punch animation
var t := create_tween()
t.tween_property(score_label, "scale", Vector2.ONE * 1.3, 0.08)
t.tween_property(score_label, "scale", Vector2.ONE, 0.1)
| Node | Use For |
|---|---|
| Label | Text display |
| ProgressBar | Health, XP, cooldowns |
| TextureProgressBar | Styled bars with custom art |
| Button | Clickable actions |
| Panel | Background containers |
| MarginContainer | Spacing from screen edges |
| HBoxContainer / VBoxContainer | Horizontal/vertical layouts |
✓ CanvasLayer for screen-space HUD ✓ Control root set to Full Rect (anchors) ✓ Containers for layout (HBox/VBox/Margin) ✓ HUD updates via signals (no polling) ✓ bind/unbind for respawn safety
When to Use GDScript UI & HUD
- Health/XP bars, cooldown indicators, and wave timers
- Score displays, level indicators, and resource counters
- Any UI that should stay fixed while the camera moves
- Games with respawn, character switching, or multiplayer
Check Your Understanding: GDScript UI & HUD
Check Your Understanding: How do you handle HUD updates when a player respawns or is replaced?
Use a bind/unbind pattern: unbind_player() disconnects signals from the old player (if any), then bind_player() connects to the new one and does an initial sync. This prevents duplicate connections and ghost updates from freed nodes. Always unbind before rebinding.
What You'll Practice: GDScript UI & HUD
Common GDScript UI & HUD Pitfalls
- Rebinding HUD without disconnecting first—causes duplicate signal connections and double updates
- Not using anchors/containers—HUD breaks on different resolutions
- Putting gameplay logic inside HUD scripts—HUD should render state, not own it
- Updating UI every frame in _process—use signals instead
- Root UI Control blocking game clicks—set mouse_filter to Ignore or Pass on non-interactive elements
- Calling get_node() repeatedly in _process—cache with @onready
- Making CanvasLayer the root of UI scenes—use a Control root so you can resize/anchor from other scenes
- Expecting queue_free() to instantly disconnect signals—it's deferred, signals can fire until end-of-frame
GDScript UI & HUD FAQ
Why does my HUD move with the camera?
Put HUD Controls under a CanvasLayer. CanvasLayer renders in screen space and stays fixed while the camera moves.
How do I make my HUD work on different resolutions?
Three things: (1) Set your root Control to Full Rect anchor preset, (2) Use containers (HBox/VBox/Margin) for layout instead of fixed positions, (3) Configure stretch settings in Project Settings → Display → Window (canvas_items mode is common).
Why use containers instead of fixed positions?
Containers auto-adjust when text changes length or resolution differs. Fixed positions break on different screen sizes. MarginContainer adds edge spacing, HBox/VBox arrange children automatically.
Do I need to disconnect signals before queue_free()?
Godot auto-disconnects when nodes are freed, but queue_free() is deferred—signals can still fire until end-of-frame. For respawn/rebind scenarios, explicit disconnect prevents edge cases.
Why use signals for HUD updates instead of _process?
Signals are event-driven: the HUD updates only when something changes, which is cleaner and avoids wasted per-frame work. It also decouples UI from gameplay.
Should UI read player stats directly?
It can for small prototypes, but signals scale better. Signals keep the HUD independent and make it easier to swap players, respawn, or run multiple game modes.
Should UI be in an autoload?
Usually no. Put the HUD as a CanvasLayer child in your main scene. Use an autoload only for truly global UI (debug overlay, notifications) that persists across scene changes.
How should I format time for a wave timer?
Convert seconds to minutes/seconds and pad with zeros: "%d:%02d" % [mins, secs]. Keep formatting in the HUD so gameplay code stays clean.
Why is my label not updating?
Check: (1) correct node path, (2) you're setting .text not calling a method, (3) the signal is actually emitting (add a print to verify).
ProgressBar vs TextureProgressBar?
ProgressBar uses theme styling (good for prototypes). TextureProgressBar lets you use custom textures for the bar fill—use it when you want stylized health/XP bars.
Why is my UI blocking clicks on the game world?
Your root Control has mouse_filter set to Stop (the default). Set it to Ignore or Pass so clicks pass through to the game. Only interactive elements (buttons) should Stop mouse events.
GDScript UI & HUD Syntax Quick Reference
extends CanvasLayer
@onready var hp_bar: ProgressBar = $UI/Margin/HPBar
@onready var hp_label: Label = $UI/Margin/HPLabel
var _player: Node
func bind_player(player: Node) -> void:
unbind_player()
_player = player
_player.health_changed.connect(_on_health_changed)
_on_health_changed(_player.hp, _player.max_hp)
func unbind_player() -> void:
if _player and _player.health_changed.is_connected(_on_health_changed):
_player.health_changed.disconnect(_on_health_changed)
_player = null
func _on_health_changed(current: int, maximum: int) -> void:
hp_bar.max_value = maximum
hp_bar.value = current
hp_label.text = "%d / %d" % [current, maximum]func _on_xp_changed(xp: int, xp_to_next: int, level: int) -> void:
xp_bar.max_value = xp_to_next
xp_bar.value = xp
level_label.text = "Lv. %d" % levelfunc format_time(seconds: int) -> String:
var mins := seconds / 60
var secs := seconds % 60
return "%d:%02d" % [mins, secs]
func _on_wave_time_changed(seconds_remaining: int) -> void:
timer_label.text = format_time(seconds_remaining)func _on_score_changed(new_score: int) -> void:
score_label.text = "Score: %d" % new_score
var t := create_tween()
t.tween_property(score_label, "scale", Vector2.ONE * 1.3, 0.08)
t.tween_property(score_label, "scale", Vector2.ONE, 0.1)GDScript UI & HUD Sample Exercises
Create a HUD script that renders on top of gameplay.
extends CanvasLayerIncrease the score by 1 when an enemy is killed.
score += 1Set $LevelLabel's text to show 'Level: ' followed by the level variable using GDScript string formatting.
$LevelLabel.text = "Level: %d" % level+ 9 more exercises