---
name: hud-system
description: Use when building in-game HUDs — health bars, score displays, minimap, notifications, and damage numbers
---
# HUD Systems in Godot 4.3+
All examples target Godot 4.3+ with no deprecated APIs. GDScript is shown first, then C#.
> **Related skills:** **godot-ui** for Control node layout and themes, **component-system** for HealthComponent integration, **event-bus** for score/notification signals, **inventory-system** for inventory UI patterns, **2d-essentials** for CanvasLayer setup and draw order, **ability-system** for cooldown bar and resource bar binding patterns.
---
## 1. HUD Architecture
### Why CanvasLayer
A `CanvasLayer` renders its children in a fixed screen-space layer that is completely independent of any `Camera2D` or `Camera3D` transform. Without it, HUD nodes attached to the scene root still move with the camera when you pan or zoom. Wrapping all HUD nodes in a `CanvasLayer` (layer `≥ 1`) ensures the HUD always stays in place regardless of camera movement.
### Scene Tree
```
World (Node2D / Node3D)
├── TileMapLayer ← game world
├── Player (CharacterBody2D)
│ ├── Camera2D
│ ├── HealthComponent
│ └── HurtboxComponent
├── Enemies
└── HUD (CanvasLayer — layer: 1)
├── MarginContainer (anchor: Full Rect — provides edge padding)
│ ├── TopBar (HBoxContainer)
│ │ ├── HealthBarPanel (PanelContainer)
│ │ │ └── HealthBar (TextureProgressBar or ProgressBar)
│ │ └── ScoreLabel (Label)
│ └── BottomBar (HBoxContainer)
│ └── InteractionPrompt (Label — hidden by default)
├── DamageNumbersLayer (Node2D — world-space spawning point)
├── MinimapContainer (SubViewportContainer)
│ └── MinimapViewport (SubViewport)
│ ├── MinimapCamera (Camera2D)
│ └── MinimapWorld (mirrors or references world nodes)
└── NotificationStack (VBoxContainer — anchored top-right)
```
**Key rules:**
- Keep all HUD scenes under a single `CanvasLayer`. Do not mix HUD nodes into the game world tree.
- Use `layer = 1` for the main HUD. Use higher values (e.g. `10`) for overlays or pause menus that must appear above the HUD.
- Damage numbers are an exception — they can live in a `Node2D` child of the `CanvasLayer` and use `get_viewport().get_screen_transform()` to convert world positions to screen positions.
---
## 2. Health Bar
### ProgressBar vs TextureProgressBar
| Node | When to use |
|---|---|
| `ProgressBar` | Prototyping, plain-colour bars |
| `TextureProgressBar` | Pixel-art or stylised bars using sprite sheets |
Both expose `min_value`, `max_value`, and `value`. Set `step = 0` so tweening produces a smooth animation rather than snapping to integer steps.
### GDScript
```gdscript
## health_bar.gd — attach to a ProgressBar or TextureProgressBar
class_name HealthBar
extends ProgressBar
## Reference to the HealthComponent this bar tracks.
## Assign in the Inspector or connect programmatically from the HUD root.
@export var health_component: HealthComponent
## Duration (seconds) for the smooth tween on health change.
@export var tween_duration: float = 0.25
var _tween: Tween
func _ready() -> void:
step = 0.0 # allow fractional values for smooth animation
if health_component:
_connect_component(health_component)
## Call this if the HealthComponent is not available at _ready time
## (e.g. the player spawns after the HUD).
func bind(component: HealthComponent) -> void:
if health_component:
health_component.health_changed.disconnect(_on_health_changed)
health_component = component
_connect_component(component)
func _connect_component(component: HealthComponent) -> void:
max_value = component.max_health
value = component.current_health
component.health_changed.connect(_on_health_changed)
func _on_health_changed(current: int, maximum: int) -> void:
max_value = maximum
_animate_to(current)
func _animate_to(target_value: float) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.set_ease(Tween.EASE_OUT)
_tween.set_trans(Tween.TRANS_QUAD)
_tween.tween_property(self, "value", target_value, tween_duration)
```
### C#
```csharp
// HealthBar.cs — attach to a ProgressBar or TextureProgressBar
using Godot;
public partial class HealthBar : ProgressBar
{
[Export] public HealthComponent HealthComponent { get; set; }
[Export] public float TweenDuration { get; set; } = 0.25f;
private Tween _tween;
public override void _Ready()
{
Step = 0.0;
if (HealthComponent != null)
ConnectComponent(HealthComponent);
}
/// Call this when the HealthComponent is not available at _Ready time.
public void Bind(HealthComponent component)
{
if (HealthComponent != null)
HealthComponent.HealthChanged -= OnHealthChanged;
HealthComponent = component;
ConnectComponent(component);
}
private void ConnectComponent(HealthComponent component)
{
MaxValue = component.MaxHealth;
Value = component.CurrentHealth;
component.HealthChanged += OnHealthChanged;
}
private void OnHealthChanged(int current, int maximum)
{
MaxValue = maximum;
AnimateTo(current);
}
private void AnimateTo(float targetValue)
{
_tween?.Kill();
_tween = CreateTween();
_tween.SetEase(Tween.EaseType.Out);
_tween.SetTrans(Tween.TransitionType.Quad);
_tween.TweenProperty(this, "value", targetValue, TweenDuration);
}
}
```
**Tip:** If you use `TextureProgressBar`, set `fill_mode` to `FILL_LEFT_TO_RIGHT` and assign your bar texture to `texture_progress`. The `value` / `max_value` ratio drives how much of the texture is revealed.
---
## 3. Score / Label Display
### GDScript
```gdscript
## score_display.gd — attach to a Label
class_name ScoreDisplay
extends Label
## Duration (seconds) to count from old to new score value.
@export var count_duration: float = 0.4
var _displayed_score: int = 0
var _tween: Tween
func _ready() -> void:
EventBus.score_changed.connect(_on_score_changed)
text = "0"
func _on_score_changed(new_score: int) -> void:
_animate_counter(_displayed_score, new_score)
func _animate_counter(from: int, to: int) -> void:
if _tween:
_tween.kill()
_tween = create_tween()
_tween.set_ease(Tween.EASE_OUT)
_tween.set_trans(Tween.TRANS_QUAD)
# Tween an intermediate float; update the label text each step.
_tween.tween_method(_set_counter_value, float(from), float(to), count_duration)
func _set_counter_value(value: float) -> void:
_displayed_score = int(value)
text = str(_displayed_score)
```
### C#
```csharp
// ScoreDisplay.cs — attach to a Label
using Godot;
public partial class ScoreDisplay : Label
{
[Export] public float CountDuration { get; set; } = 0.4f;
private int _displayedScore = 0;
private Tween _tween;
public override void _Ready()
{
EventBus.Instance.ScoreChanged += OnScoreChanged;
Text = "0";
}
private void OnScoreChanged(int newScore)
{
AnimateCounter(_displayedScore, newScore);
}
private void AnimateCounter(int from, int to)
{
_tween?.Kill();
_tween = CreateTween();
_tween.SetEase(Tween.EaseType.Out);
_tween.SetTrans(Tween.TransitionType.Quad);
_tween.TweenMethod(
Callable.From(SetCounterValue),
(double)from,
(double)to,
CountDuration
);
}
private void SetCounterValue(double value)
{
_displayedScore = (int)value;
Text = _displayedScore.ToString();
}
}
```
**EventBus signals needed:**
```gdscript
# autoloads/event_bus.gd
signal score_changed(new_score: int)
```
```csharp
// EventBus.cs (partial — score signal)
[Signal] public delegate void ScoreChangedEventHandler(int newScore);
```
Emit from wherever points are awarded:
```gdscript
# Inside a collectible or enemy death handler
EventBus.score_changed.emit(GameState.score)
```
```csharp
// Inside a collectible or enemy death handler
EventBus.Instance.EmitSignal(EventBus.SignalName.ScoreChanged, GameState.Score);
```
---
## 4. Damage Numbers
Floating "−25" labels that rise and fade above the hit point. Pooled in a HUD-side spawner; world position converted to screen via `get_viewport().get_canvas_transform()`. Optional crit colorization before spawn.
> See [references/damage-numbers.md](references/damage-numbers.md) for the full GDScript and C# DamageNumber scene + pooled spawner.
---
## 5. Notification System
Toast / notification stack — a `VBoxContainer` anchored top-right with `max_visible` clamping and queue-driven dismissal. New toasts wait for an old one to expire before showing.
> See [references/notifications.md](references/notifications.md) for the full GDScript and C# stack with auto-dismiss timers.
---
## 6. Minimap Concept
Render a top-down view via a dedicated `SubViewport` + `Camera2D` that follows the player. Display the SubViewport texture in a `TextureRect` inside the HUD. Optional circular mask via `ColorRect` shader. Set `render_target_update_mode = UPDATE_ALWAYS`.
> See [references/minimap.md](references/minimap.md) for the SubViewport setup, MinimapCamera GDScript + C#, and circular-mask shader.
---
## 7. Interaction Prompts
Screen-space "Press [E] to interact" prompt — a `Label` inside the HUD that follows an interactable's screen position each frame. Driven by `body_entered` / `body_exited` on the interactable's `Area2D`. Use `InputMap.action_get_events(name)` to display the correct key for the player's current binding.
> See [references/interaction-prompts.md](references/interaction-prompts.md) for the full GDScript and C# prompt + Interactable Area2D pair.
---
## 8. Checklist
- [ ] All HUD nodes are children of a `CanvasLayer` with `layer >= 1` so they are unaffected by camera transforms
- [ ] `ProgressBar.step` is set to `0.0` for smooth tween animation rather than integer snapping
- [ ] Health bar binds to `HealthComponent.health_changed` signal — does not poll in `_process`
- [ ] Tween is killed (`_tween.kill()`) before starting a new one so rapid damage does not stack animations
- [ ] Score counter uses `tween_method` to interpolate the displayed integer — not a jump cut
- [ ] Damage number positions are converted from world space to screen space using `get_viewport().get_canvas_transform()`
- [ ] Damage number pool size is large enough that labels are not recycled before their tween completes
- [ ] Notification stack enforces `max_visible` and re-checks the queue after each dismissal
- [ ] Toast auto-dismiss uses a `Timer` node — not `await get_tree().create_timer()`
- [ ] `SubViewport` for minimap has `render_target_update_mode = UPDATE_ALWAYS`
- [ ] Minimap `Camera2D` zoom and cull mask are configured so only the intended layers are visible
- [ ] Interaction prompt converts the interactable's world position each frame — not cached at spawn time
- [ ] `InputMap.action_get_events()` is used to display the correct key for the player's current binding
- [ ] HUD nodes that do not need input set `mouse_filter = MOUSE_FILTER_IGNORE` to avoid blocking game clicks