Lecture W7T1: UI, Menus, and One Careful Global
▶ PRESS START
Lucas P. Cordova, Ph.D.
Willamette University | Tuesday, October 6, 2026
Laptops out today. The proposal is due Thursday, October 8, 9:59 PM (PDF in Canvas and in your team repo). Lab 4 is due Tuesday, October 20, 9:59 PM.
Six games, sixty seconds each
| Beat | What you say | About |
|---|---|---|
| Name and anchor | Working title, plus your 20 Games Challenge anchor [6]. | 10 s |
| The loop | The loop in one breath, plus the one twist that makes it yours. | 20 s |
| The feel | Your aesthetics, and the moment you most want a player to experience. | 15 s |
| The risk | The thing you are least sure you can build, and what you try first. | 15 s |
No slides or no demo, A pitch that runs long is the game asking for a smaller scope.
Every pitch, jot two things (index card or notebook):
Two of you become each team's stakeholders at the proposal gate this Thursday. The questions you write today are your first review notes.
One more listening job: for each game, guess what its HUD has to show. You will need that guess at the end of class.
Control nodes, anchors, and containers
| The game world | The interface | |
|---|---|---|
| Base node | Node2D (blue icons) | Control (green icons) |
| Placed by | A position in world pixels | A rectangle attached to its parent's edges |
| Lives in | The arena. A camera can scroll past it. | The glass of the screen. It stays put. |
| Examples | Blitz, the rival, walls, the fig | Score, timer, menus, buttons, health bars |
A CanvasLayer is a separate sheet of glass drawn over the game. Interface nodes go under one so that no camera, shake, or zoom ever moves them [3].
Fingers up. The window is 1152 pixels wide. A label sits at x = 1000 with the default Top Left anchors. A player drags the window down to 800 pixels wide. Where is the label?
1: it slides left and stays near the right edge. 2: it stays at x = 1000 and falls off the screen. 3: everything shrinks to fit.
| Container | What it does to its children |
|---|---|
| MarginContainer | Pads them away from its edges. |
| HBoxContainer VBoxContainer | Lines them up in a row, or stacks them in a column. |
| CenterContainer | Centers them. |
| PanelContainer | Draws a background panel behind them. |
A child of a container hands over its position and size. You steer it with two things: its minimum size, and its Container Sizing flags (Fill, Expand) [4].
Position and size greyed out in the Inspector? The parent is a container. That is the container doing its job.
RIVAL-BRAINS, BY THE END OF CLASSroot ├─ Scoreboard round 3: an autoload └─ Main (Node2D) game.gd, the brain ├─ Walls, Player, Rival, Fig └─ UI (CanvasLayer) ├─ Banner (Label) from Week 5 ├─ HUD round 1: hud.tscn └─ PauseMenu round 2: pause_menu.tscn
Each new piece is its own small scene with its own script. Thursday's git lesson, applied: you could build the HUD while your teammate builds the menu, and never touch the same file.
A heads-up display.
Every step is on these slides. Keep the deck open beside Godot.
git clone https://github.com/LucasCordova/rival-brains.git cd rival-brains git switch demo5 # where last week ended git switch -c hud-coop # your own branch for today
Then, in Godot:
rival-brains folder, and open it.Already have the clone? Run git fetch --tags first, then the two switch lines.
Each finished round is saved in the repo. Jumping to one sets aside your own work and gives you a clean, running copy.
| To start from | Run this in the rival-brains folder |
|---|---|
| Round 1 finished | git stash -u then git switch --detach demo6-lap1 |
| Round 2 finished | git stash -u then git switch --detach demo6-lap2 |
| Everything finished | git stash -u then git switch demo6 |
After any jump, accept Godot's reload prompt. After jumping to demo6, also choose Project, then Reload Current Project: that branch changes the project settings, and Godot keeps the old ones in memory.
In Godot:
MarginContainer, select it, click Create.HUD.16.hud.tscn.HUD.TSCN SO FARHUD (MarginContainer)
Top Wide pins the HUD to the top edge and stretches it from the left edge to the right edge.
Still in hud.tscn:
HBoxContainer. Rename it Row.Label. Rename it TimeLabel. In the Inspector set Text to TIME 0.0.Control. Rename it Spacer. In the Inspector: Layout, Container Sizing, Horizontal, check Expand.Label. Rename it BestLabel. Set Text to BEST 0.0.28.HUD.TSCNHUD (MarginContainer) └─ Row (HBoxContainer) ├─ TimeLabel (Label) ├─ Spacer (Control) └─ BestLabel (Label)
The names matter. The script finds these nodes by path, and $Row/TimeLabel has to match exactly.
Give the HUD a script:
hud.gd, click Create.extends MarginContainer @onready var time_label: Label = $Row/TimeLabel @onready var best_label: Label = $Row/BestLabel func show_time(seconds: float) -> void: time_label.text = "TIME %.1f" % seconds func show_best(seconds: float) -> void: best_label.text = "BEST %.1f" % seconds
The HUD has one job: show numbers it is handed. It keeps no clock and makes no decisions. "TIME %.1f" % seconds formats the number with one decimal place.
Put the HUD in the main scene:
main.tscn (double-click it in the FileSystem dock).hud.tscn, click Open.MAIN.TSCNMain (Node2D) ├─ Walls ├─ Player ├─ Rival ├─ Fig └─ UI (CanvasLayer) ├─ Banner └─ HUD
Running now shows TIME 0.0 and BEST 0.0, frozen. Nobody is handing the HUD any numbers yet.
Open game.gd. Three small edits near the top. Lines marked # new are added; the rest already exists and shows you where.
# game.gd: two new variables, under rival_start var time_alive: float = 0.0 var best_time: float = 0.0 # one new line, under the banner line @onready var hud: MarginContainer = $UI/HUD # _delta becomes delta, because now we use it func _physics_process(delta: float) -> void: match state: State.PLAYING: time_alive += delta # new hud.show_time(time_alive) # new
Still in game.gd, in _change_state (the door from Week 5). Add the marked lines, then save.
# game.gd, inside _change_state State.TITLE: rival.calm() time_alive = 0.0 # new hud.show_time(time_alive) # new hud.show_best(best_time) # new State.CAUGHT: if time_alive > best_time: # new best_time = time_alive # new hud.show_best(best_time) # new
Calls down. The game owns the HUD, so it calls the HUD's functions. The door is the one place a round begins and ends, so the clock resets there.
In TITLE, the three lines go right after rival.calm(). In CAUGHT, they go first.
Round clear, on both screens:
Side quest: turn TIME yellow after ten seconds, or give both labels a shadow (Theme Overrides, Colors and Constants).
Steps 1 to 6 are the six slides above this one.
| You see | Cause and cure |
|---|---|
An error naming show_time and Nil | The game cannot find $UI/HUD. The HUD is missing from main.tscn, sits outside UI, or has another name. |
An error with Row/TimeLabel in it | A node name in hud.tscn differs from the script. Rename the node to match. |
delta is not declared | The parameter still reads _delta. Remove the underscore. |
| Both labels bunched at the left | Spacer is missing Expand, or it is not between the two labels. |
| A small HUD that ignores the window width | The Anchors Preset on HUD is not Top Wide. |
| TIME never moves | An unsaved script, or the two new lines are outside State.PLAYING. |
Freeze the world, keep the menu awake
get_tree().paused = true
_process, no _physics_process, no input callbacks [4].game.gd freezes.Every node has a Process Mode. The default, Inherit, pauses with the tree. Always keeps a node (and the children that inherit from it) running while everything else is frozen.
A new scene:
ColorRect, click Create. Rename it PauseMenu.pause_menu.tscn.Step 4 is the whole trick. Skip it and the menu opens, then ignores you forever.
Still in pause_menu.tscn:
CenterContainer. Rename it Center. Set its Anchors Preset to Full Rect (same two settings as step 1).VBoxContainer. Rename it Menu.Label. Rename it Title. Text: PAUSED. Font Size: 40.Button. Rename it ResumeButton. Text: Resume.Button. Rename it RestartButton. Text: Restart.PAUSE_MENU.TSCNPauseMenu (ColorRect) └─ Center (CenterContainer) └─ Menu (VBoxContainer) ├─ Title (Label) ├─ ResumeButton └─ RestartButton
Two containers, zero pixel math. Add a third button later and the column re-centers itself.
Right-click PauseMenu, Attach Script, keep pause_menu.gd, Create. Replace the whole file, then save.
extends ColorRect var allowed: bool = false func _ready() -> void: hide() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel") and (allowed or get_tree().paused): _set_paused(not get_tree().paused) func _set_paused(paused: bool) -> void: get_tree().paused = paused visible = paused if paused: $Center/Menu/ResumeButton.grab_focus()
ui_cancel is Godot's built-in action for Escape. Menus use ui_ actions.allowed is set by the game: pausing only makes sense mid-chase.grab_focus() lets the keyboard press the button.Signals, the Lab 2 way:
pressed(). In the dialog, PauseMenu is already selected. Click Connect.func _on_resume_button_pressed() -> void: _set_paused(false) func _on_restart_button_pressed() -> void: get_tree().paused = false get_tree().reload_current_scene()
Signals up. A button knows nothing about pausing. It announces pressed, and its owner decides what that means.
Back in the main scene:
main.tscn. Select UI. Click Instantiate Child Scene and pick pause_menu.tscn.game.gd and add the two lines below. Save everything.# game.gd: one new line, under the hud line @onready var pause_menu: ColorRect = $UI/PauseMenu # game.gd, inside _change_state, under the two set_physics_process lines pause_menu.allowed = running
Restart calls get_tree().reload_current_scene(). Godot frees the whole Main scene and builds a fresh one from main.tscn.
Before the round starts, fingers up. You play, you set a best time of 8.4 seconds, you pause, you press Restart. What does the HUD say?
1: BEST 8.4. 2: BEST 0.0. 3: the game crashes.
Keep your vote. You are about to find out on your own machine.
Round clear, on both screens:
Side quest: add a Quit button that calls get_tree().quit(), or wrap Menu in a PanelContainer.
Steps 1 to 5 are the slides above this one.
| You see | Cause and cure |
|---|---|
| The menu opens and nothing responds | Process Mode on PauseMenu is still Inherit. Set it to Always (step 1). |
| Escape does nothing mid-chase | The pause_menu.allowed = running line is missing from game.gd, or PauseMenu is not under UI. |
| Clicking a button does nothing | The pressed signal is not connected. Look for the green icon beside the function. |
| Enter does not press Resume | The grab_focus() line is missing, or the node is not named ResumeButton. |
| The menu is visible from the first frame | hide() is missing from _ready. |
| The banner draws over the menu | PauseMenu must be the last child of UI. |
Autoloads, and the discipline to keep them small
THE RUNNING TREEroot └─ Main freed and rebuilt on Restart ├─ game.gd var best_time lived here ├─ Player, Rival, Fig └─ UI ├─ HUD └─ PauseMenu
reload_current_scene() replaces every node in the scene. Each variable starts over.Some data has to outlive the scene. It needs a home above Main.
An autoload is a script or scene that Godot adds as a child of root before your first scene loads. It stays there when scenes change or reload, and every script can reach it by name [2].
WITH AN AUTOLOADroot ├─ Scoreboard stays for the whole session └─ Main freed and rebuilt on Restart
Scoreboard.report(time_alive)
Any script, anywhere, with no node path.
This is Godot's take on the Singleton pattern: exactly one instance, reachable from everywhere [1].
Reachable from everywhere is exactly what made last Thursday's Berry Bandit fragile. Lines 28 and 29:
Game.score += 50 get_node("/root/Main/HUD/ScoreLabel").text = "Score: " + str(Game.score)
Game.score, so when it is wrong, every script is a suspect.Thumbs up for autoload, thumbs down for keep it in the scene. Vote before each verdict appears.
| On trial | Verdict | Because |
|---|---|---|
| The best time across restarts | GLOBAL | It must outlive the scene. |
| Blitz's current health | LOCAL | HealthComponent owns it. A new round wants a fresh one. |
| Music that plays through every screen | GLOBAL | A scene change would cut it off. |
| The rival's chase speed | LOCAL | It is tuning data. It lives in a .tres file. |
| A reference to the HUD's label | LOCAL | That node dies with the scene. A global pointing at it crashes. |
| The player's volume setting | GLOBAL | One value, every scene, the whole session. |
Scoreboard keeps best times. A file named Globals or Manager has no job yet.Same rules as parts from Week 6. An autoload is a part whose owner is the whole game.
A script with no scene:
res://, choose Create New, then Script.scoreboard.gd. Inherits: Node. Click Create.extends Node signal best_changed(seconds: float) var best_time: float = 0.0 func report(seconds: float) -> void: if seconds > best_time: best_time = seconds best_changed.emit(best_time)
Ten lines. It owns one number, guards it with one function, and announces when it changes. Nobody else can set a best time lower by accident.
Tell Godot to load it first:
scoreboard.gd.Scoreboard. Click Add.The Node Name is the global name. From this moment every script in the project can write Scoreboard.
In game.gd: delete the var best_time line and the hud.show_best(best_time) line in TITLE. In CAUGHT, replace the three best-time lines with one:
# game.gd, inside _change_state State.CAUGHT: Scoreboard.report(time_alive)
In hud.gd, add a _ready function, then save both scripts:
# hud.gd: a new function, above show_time func _ready() -> void: show_best(Scoreboard.best_time) Scoreboard.best_changed.connect(show_best)
The game reports. The HUD listens. Neither knows the other exists.
Round clear, on both screens:
Side quest: flash BEST yellow on a new record, or show NEW RECORD on the caught banner. Decide first: who should hear best_changed?
Seeing Scoreboard is not declared? Step 2 did not stick: reopen Project Settings and check the name and the Enable box.
What you know now
3 minutes, with your teammate, on an index card:
This card is a paragraph of your proposal's design detail, already drafted. Compare it with the guess you made during that team's pitch.
get_tree().paused freezes the tree. Process Mode Always keeps the menu awake.Thursday, October 8: agile for game teams, stakeholders assigned, and the proposal due at 9:59 PM. Thursday, October 15: the prototype gate. Tuesday, October 20: Lab 4.