Why UI Uses Control Nodes (and Not Node2D)
In Godot 4, gameplay elements usually live in the 2D world (Node2D, CharacterBody2D, etc.), while user interface lives in a separate, screen-space system built around Control nodes. Control nodes are designed for resolution independence: they can anchor to screen edges, stretch with the viewport, and arrange themselves using containers.
In this chapter you will build three UI pieces as separate scenes:
- A HUD (score + health) that stays visible during gameplay
- A Pause Menu that can be toggled on/off
- A Game Over screen shown when the player dies
You will also wire UI updates using signals so the UI reacts to changes (score/health) instead of checking values every frame.
Project UI Structure (Scenes You Will Create)
| Scene | Root Node | Purpose |
|---|---|---|
HUD.tscn | CanvasLayer | Always-on overlay: score label + health bar |
PauseMenu.tscn | Control | Pause overlay with buttons (Resume, Restart, Quit) |
GameOver.tscn | Control | Game over overlay with final score and actions |
GameManager.gd (autoload) | Node | Centralized state + signals + scene switching |
Using separate scenes keeps UI modular and makes it easy to show/hide overlays without cluttering the main level scene.
Step 1: Create a Centralized GameManager (Autoload)
1.1 Create the script
Create res://autoload/GameManager.gd:
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
extends Node
signal score_changed(new_score: int)
signal health_changed(current: int, max: int)
signal game_paused_changed(is_paused: bool)
signal game_over(final_score: int)
var score: int = 0
var max_health: int = 100
var health: int = 100
var _current_level_path: String = ""
func start_level(level_scene_path: String) -> void:
_current_level_path = level_scene_path
score = 0
health = max_health
emit_signal("score_changed", score)
emit_signal("health_changed", health, max_health)
get_tree().paused = false
emit_signal("game_paused_changed", false)
get_tree().change_scene_to_file(level_scene_path)
func add_score(amount: int) -> void:
score += amount
emit_signal("score_changed", score)
func set_health(value: int) -> void:
health = clamp(value, 0, max_health)
emit_signal("health_changed", health, max_health)
if health <= 0:
emit_signal("game_over", score)
func damage(amount: int) -> void:
set_health(health - amount)
func heal(amount: int) -> void:
set_health(health + amount)
func toggle_pause() -> void:
set_paused(not get_tree().paused)
func set_paused(value: bool) -> void:
get_tree().paused = value
emit_signal("game_paused_changed", value)
func restart_level() -> void:
if _current_level_path != "":
start_level(_current_level_path)
func quit_to_main_menu(main_menu_path: String) -> void:
get_tree().paused = false
emit_signal("game_paused_changed", false)
get_tree().change_scene_to_file(main_menu_path)1.2 Register as Autoload
In Project > Project Settings > Autoload:
- Path:
res://autoload/GameManager.gd - Name:
GameManager - Enable it
Now you can access it from anywhere as GameManager, and UI can subscribe to its signals.
Step 2: Build the HUD Scene (Score + Health)
2.1 Create HUD.tscn
Create a new scene HUD.tscn with root CanvasLayer. A CanvasLayer draws on top of the world and is not affected by the camera, which is ideal for HUDs.
Suggested node tree:
HUD (CanvasLayer)
└── SafeArea (MarginContainer)
└── TopBar (HBoxContainer)
├── LeftGroup (HBoxContainer)
│ ├── HeartIcon (TextureRect)
│ └── HealthBar (ProgressBar)
└── RightGroup (HBoxContainer)
├── ScoreText (Label)
└── ScoreValue (Label)2.2 Configure anchors and responsive layout
To make the HUD behave well across resolutions, combine anchors with containers:
- SafeArea (MarginContainer): set anchors to full rect so it stretches to the viewport. Add margins (Theme overrides or Inspector) so content does not touch edges.
- TopBar (HBoxContainer): it will lay out children horizontally and adapt to width changes.
- LeftGroup and RightGroup: use containers to keep elements grouped and aligned.
Practical settings that work well:
SafeArea: Layout preset Full RectTopBar:Alignment= Space Between (so left and right groups separate)HealthBar: setCustom Minimum Size(e.g., X=200) so it remains readableHeartIcon: setStretch Modeto keep aspect; set a small min size (e.g., 24x24)
2.3 HUD script: react to GameManager signals
Attach a script to the root HUD:
extends CanvasLayer
@onready var health_bar: ProgressBar = %HealthBar
@onready var score_value: Label = %ScoreValue
func _ready() -> void:
GameManager.score_changed.connect(_on_score_changed)
GameManager.health_changed.connect(_on_health_changed)
# Initialize UI immediately (useful when HUD is instanced after values already exist)
_on_score_changed(GameManager.score)
_on_health_changed(GameManager.health, GameManager.max_health)
func _on_score_changed(new_score: int) -> void:
score_value.text = str(new_score)
func _on_health_changed(current: int, max_value: int) -> void:
health_bar.max_value = max_value
health_bar.value = currentThis is fully reactive: whenever gameplay code calls GameManager.add_score() or GameManager.damage(), the HUD updates without any _process polling.
2.4 Add HUD to your level scene
Open your main level scene and instance HUD.tscn as a child of the level root (or a dedicated UI parent). Because it is a CanvasLayer, it will render above the world.
Anchors vs Containers: When to Use Which
Responsive UI in Godot typically uses both:
- Anchors define where a Control node attaches in the viewport (top-left, center, full-rect, etc.). Use them to position major UI regions.
- Containers handle layout inside a region (horizontal/vertical stacking, spacing, alignment). Use them to avoid manual positioning.
A common pattern is: Control (Full Rect) → MarginContainer → VBox/HBox/Grid containers → actual widgets (Label, Button, ProgressBar).
Step 3: Create a Pause Menu Scene
3.1 Build PauseMenu.tscn
Create PauseMenu.tscn with root Control. Suggested tree:
PauseMenu (Control)
├── Dim (ColorRect)
└── Center (CenterContainer)
└── Panel (PanelContainer)
└── Layout (VBoxContainer)
├── Title (Label)
├── ResumeButton (Button)
├── RestartButton (Button)
└── QuitButton (Button)Recommended layout settings:
PauseMenu: Layout preset Full RectDim: Layout preset Full Rect, color black with alpha (e.g., 0.5) to dim the gameCenter: Layout preset Full Rect so the panel stays centered at any resolution
3.2 Make the menu work while paused
When the game is paused (get_tree().paused = true), nodes stop processing by default. UI should still respond to clicks, so set the pause behavior:
- Select the root
PauseMenunode - Set Process > Mode to When Paused
Do the same for any UI nodes that must remain interactive while paused (usually setting it on the root is enough).
3.3 PauseMenu script
Attach a script to PauseMenu:
extends Control
@export var main_menu_path: String = "res://scenes/MainMenu.tscn"
func _ready() -> void:
visible = false
process_mode = Node.PROCESS_MODE_WHEN_PAUSED
GameManager.game_paused_changed.connect(_on_game_paused_changed)
%ResumeButton.pressed.connect(_on_resume_pressed)
%RestartButton.pressed.connect(_on_restart_pressed)
%QuitButton.pressed.connect(_on_quit_pressed)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
GameManager.toggle_pause()
get_viewport().set_input_as_handled()
func _on_game_paused_changed(is_paused: bool) -> void:
visible = is_paused
func _on_resume_pressed() -> void:
GameManager.set_paused(false)
func _on_restart_pressed() -> void:
GameManager.restart_level()
func _on_quit_pressed() -> void:
GameManager.quit_to_main_menu(main_menu_path)This menu is driven by the centralized pause signal. Any system can pause/unpause through GameManager, and the menu will follow automatically.
3.4 Add PauseMenu to the level
Instance PauseMenu.tscn into your level scene (or into a dedicated UI scene). Because it is a Control with full-rect layout, it will cover the screen when visible.
Step 4: Create a Game Over Screen Scene
4.1 Build GameOver.tscn
Create GameOver.tscn with root Control:
GameOver (Control)
├── Dim (ColorRect)
└── Center (CenterContainer)
└── Panel (PanelContainer)
└── Layout (VBoxContainer)
├── Title (Label)
├── FinalScore (Label)
├── RestartButton (Button)
└── QuitButton (Button)Use the same responsive approach: full-rect root, dim background, centered panel.
4.2 GameOver script (react to GameManager.game_over)
extends Control
@export var main_menu_path: String = "res://scenes/MainMenu.tscn"
func _ready() -> void:
visible = false
process_mode = Node.PROCESS_MODE_ALWAYS
GameManager.game_over.connect(_on_game_over)
%RestartButton.pressed.connect(_on_restart_pressed)
%QuitButton.pressed.connect(_on_quit_pressed)
func _on_game_over(final_score: int) -> void:
visible = true
%FinalScore.text = "Final Score: %s" % str(final_score)
GameManager.set_paused(true)
func _on_restart_pressed() -> void:
visible = false
GameManager.restart_level()
func _on_quit_pressed() -> void:
GameManager.quit_to_main_menu(main_menu_path)Here the game over screen pauses the game to freeze gameplay. The UI remains interactive because it is set to always process (or you can set it to when paused, as long as it becomes visible before pausing).
Step 5: Centralized Scene Switching Patterns
There are two common approaches for managing scene switching cleanly:
- Autoload GameManager (used above): simple, globally accessible, good for small games.
- Dedicated Scene Controller scene: a root scene that stays loaded and instances/uninstances levels and overlays as children. This is useful if you want transitions, loading screens, or to avoid
change_scene_to_file.
Optional: SceneController approach (instancing instead of change_scene)
If you prefer not to change the entire scene tree, create a SceneController root scene that contains:
SceneController (Node)
├── WorldRoot (Node)
└── UIRoot (CanvasLayer)Then load levels into WorldRoot and UI into UIRoot. The key idea is the same: one place owns scene lifetime, and gameplay/UI communicate through signals.
Step 6: Hook Gameplay Events to GameManager (Signal-Driven UI)
Your HUD should not fetch score/health from the player every frame. Instead, gameplay code should report changes once, and the UI reacts.
6.1 Example: when an enemy is defeated
Wherever you handle enemy defeat (enemy script, spawner, or combat system), call:
GameManager.add_score(100)6.2 Example: when the player takes damage
When the player is hit:
GameManager.damage(10)Because GameManager emits health_changed and potentially game_over, the HUD and Game Over screen update automatically.
UI Quality Tips (Practical)
- Use Theme Overrides on Labels/Buttons for consistent font size and colors, instead of changing each node individually.
- Prefer containers over manual positioning; it reduces layout bugs at different aspect ratios.
- Keep UI scenes self-contained: each UI scene connects to
GameManagerin_ready()and exposes only minimal exports (likemain_menu_path). - Initialize UI immediately after connecting signals (call the handler once) to avoid empty labels when the HUD is instanced mid-game.