Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

UI, HUD, and Menus with Control Nodes in Godot 4

Capítulo 8

Estimated reading time: 9 minutes

+ Exercise

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)

SceneRoot NodePurpose
HUD.tscnCanvasLayerAlways-on overlay: score label + health bar
PauseMenu.tscnControlPause overlay with buttons (Resume, Restart, Quit)
GameOver.tscnControlGame over overlay with final score and actions
GameManager.gd (autoload)NodeCentralized 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 App

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 Rect
  • TopBar: Alignment = Space Between (so left and right groups separate)
  • HealthBar: set Custom Minimum Size (e.g., X=200) so it remains readable
  • HeartIcon: set Stretch Mode to 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 = current

This 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)MarginContainerVBox/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 Rect
  • Dim: Layout preset Full Rect, color black with alpha (e.g., 0.5) to dim the game
  • Center: 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 PauseMenu node
  • 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 GameManager in _ready() and exposes only minimal exports (like main_menu_path).
  • Initialize UI immediately after connecting signals (call the handler once) to avoid empty labels when the HUD is instanced mid-game.

Now answer the exercise about the content:

When creating a HUD that should always stay visible and not be affected by the camera, which root node choice best fits this purpose in Godot 4?

You are right! Congratulations, now go to the next page

You missed! Try again.

A HUD should be an always-on overlay in screen space. A CanvasLayer draws above the world and is not affected by the camera, making it ideal for score and health UI.

Next chapter

Level Structure, Game Flow, and Best-Practice Project Organization

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.