Lesson Goal
You already have combat, quests, inventory, and buses. Players still need clear surfaces: a place to start the run, at-a-glance feedback in-level, and a first visit experience that teaches controls without walls of text.
By the end of this lesson, you will:
- ship a main menu scene with Continue disabled until a save exists (stub check is fine until Lesson 11)
- stack a HUD
CanvasLayerwith health, objective text, and an inventory summary fed by autoload signals - add a pause overlay that stops the action but keeps UI responsive
- gate first-run hints behind a tiny settings flag stored on disk
Step 1 - Folder and scene ownership
Under res://ui/ (see Lesson 2: Godot Project Architecture and Naming if you skipped the layout), add:
main_menu.tscnhud.tscnpause_layer.tscn- optional
onboarding_hint.tscn
Pro tip:
Keep UI scenes free of gameplay nodes. They should talk to the world through autoloads and groups, not direct references to the player body.
Step 2 - Main menu layout
Root: Control set to full rect (layout_mode full rect, anchors preset full).
Child structure:
ColorRectorTextureRectfor backdropVBoxContainercentered withPlay,Options(stub),QuitAudioStreamPlayerchild withbus = "UI"for click and hover (optional)
Script main_menu.gd:
extends Control
@export var game_scene: PackedScene
func _on_play_pressed() -> void:
if game_scene:
get_tree().change_scene_to_packed(game_scene)
func _on_quit_pressed() -> void:
get_tree().quit()
Mini task:
Wire Project Settings > Application > Run > Main Scene to main_menu.tscn so testers always land in your shell first.
Common mistake:
Placing menu buttons in pixels only. Test at 1280x720 and 1920x1080; use containers plus minimum sizes instead of hard-coded absolute positions for everything.
Step 3 - HUD CanvasLayer
Create hud.tscn with root CanvasLayer (layer = 10 so it draws above the world).
Inside, use a MarginContainer pinned to the top-left and top-right:
- Left column:
Labelfor health or stamina placeholder - Right column:
Labelfor objective summary from your quest runner - Bottom strip:
Labelfor inventory string (replace the debug label from Lesson 8: Inventory and Item Systems)
Example hud.gd hook (matches Lesson 7: QuestRunner lives in the level, not /root):
extends CanvasLayer
@export var quest_runner_path: NodePath
@onready var objective_label: Label = $RootColumn/RightAlign/ObjectiveLabel
@onready var inventory_label: Label = $RootColumn/Bottom/InventoryLabel
func _ready() -> void:
if has_node("/root/Inventory"):
Inventory.inventory_changed.connect(_refresh_inventory)
_refresh_inventory()
var runner: Node = get_node_or_null(quest_runner_path)
if runner and runner.has_signal("quest_completed"):
runner.quest_completed.connect(_on_quest_completed)
if has_node("/root/ProgressState"):
ProgressState.flag_changed.connect(func(_n, _v): _refresh_objective_from_runner(get_node_or_null(quest_runner_path)))
_refresh_objective_from_runner(runner)
func _refresh_inventory() -> void:
inventory_label.text = _format_inventory()
func _format_inventory() -> String:
var parts: PackedStringArray = []
for id in Inventory.counts.keys():
if Inventory.count_of(id) > 0:
parts.append("%s x%s" % [id, Inventory.count_of(id)])
return " | ".join(parts)
func _refresh_objective_from_runner(runner: Node) -> void:
if runner == null:
return
var q: Resource = runner.get("active_quest")
if q:
objective_label.text = q.get("title")
func _on_quest_completed(_quest_id: String) -> void:
objective_label.text = "Quest complete"
In the inspector, point quest_runner_path at the level’s QuestRunner node (see Lesson 7: Quest and Dialogue Systems). If you renamed signals, keep the same pattern: one NodePath export beats deep ../../../ chains.
Pro tip:
HUD text functions belong in *`format`** helpers so localization swaps in Lesson 14 do not scatter string logic.
Step 4 - Pause layer
pause_layer.tscn: root CanvasLayer with a dim ColorRect (modulate semi-transparent black) and a centered VBoxContainer with Resume and Main Menu.
Script essentials:
extends CanvasLayer
var paused: bool = false
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("ui_cancel"):
set_paused(not paused)
func set_paused(value: bool) -> void:
paused = value
get_tree().paused = value
visible = value
func _on_resume_pressed() -> void:
set_paused(false)
func _on_main_menu_pressed() -> void:
get_tree().paused = false
get_tree().change_scene_to_file("res://ui/main_menu.tscn")
Mark buttons and this layer with Process > Mode = Always so clicks work while the tree is paused.
Common mistake:
Forgetting to set process_mode on UI that must work during get_tree().paused. Nothing feels cheaper than a pause menu you cannot click.
Step 5 - First-run hints with ConfigFile
Add autoload PlayerSettings at res://systems/settings/player_settings.gd:
extends Node
const PATH := "user://settings.cfg"
const SECTION := "onboarding"
const KEY_HINTS := "show_move_hints"
func should_show_move_hints() -> bool:
var cfg := ConfigFile.new()
var err := cfg.load(PATH)
if err != OK:
return true
return bool(cfg.get_value(SECTION, KEY_HINTS, true))
func dismiss_move_hints() -> void:
var cfg := ConfigFile.new()
cfg.load(PATH)
cfg.set_value(SECTION, KEY_HINTS, false)
cfg.save(PATH)
Place a small PanelContainer near the bottom of the HUD with WASD / stick copy. On interact or jump pressed, call dismiss_move_hints() and hide the panel if should_show_move_hints() was true.
This pattern survives restarts and avoids abusing ProgressState for pure UX flags.
Step 6 - Audio polish on UI bus
From Lesson 9: Audio and Ambience Layering, route menu AudioStreamPlayer nodes to UI. Keep volumes a few dB lower than combat SFX so clicks never mask hit feedback.
Troubleshooting
- Labels stuck at default text: signal never emitted or wrong node path; confirm autoload names match
/root/Inventoryin Remote tab during play. - Pause freezes UI:
process_modeon controls leftInherited; set pause-victim nodes toPausableand UI shell toAlways. - Main menu scales oddly on ultrawide: add
Maxwidth on center container or clamp background to a centered aspect panel. - Hints never disappear:
dismiss_move_hintsnot called, or you load a freshConfigFilewithout saving section keys consistently.
Common Mistakes to Avoid
- coupling HUD scripts to deep scene paths (
get_node("../../../Player")) instead of signals from a facade autoload - drawing HUD below the tilemap because
CanvasLayer.layeris zero or negative unintentionally - shipping without a focus neighbor chain for gamepad; at least set default focus to Play on menu ready
Mini Challenge
Add an Options submenu with one real toggle: fullscreen using DisplayServer.window_set_mode. Persist the choice in the same settings.cfg under section video.
FAQ
Control vs CanvasItem for menus?
Menus are usually Control trees under CanvasLayer. Raw CanvasItem draws are rare for standard UI.
Should dialogue reuse this HUD?
You can, but many teams use a second CanvasLayer with higher layer so chat can appear above HUD chrome without reordering nodes each frame.
When do I theme everything?
After function works. Add a Theme resource in Lesson 14 polish if you want one pass for typography and nine-slice panels.
Quick Recap
You now have:
- an entry main menu with clean scene changes
- a signal-driven HUD that reflects quest and inventory state
- a pause UX that respects the pause tree
- first-run hints backed by
user://config, not hard-coded level checks
Next, you will formalize save data and checkpoints so Continue stops being a stub. Continue to Lesson 11: Save System and Checkpoints.
For Control layout reference while you tweak anchors, keep the Godot Game Development guide open beside this course.
Bookmark this lesson before you add localization; centralized format helpers here save rework later.
