Upgrade List UI¶
What you'll learn
- The distinction between scene-time UI (children authored in the editor, baked into the
.tscn) and runtime UI (children created in code, added during_ready()or in response to events). - How to populate a
VBoxContainerfrom an@export var all_upgrades: Array[UpgradeData]at runtime, producing one row per data entry. - The two main shapes for a per-row UI element:
Button.new()(one node, code-driven) versusPackedScene.instantiate()(a designed scene with multiple children, replicated). queue_free()and the deferred-free pattern that makes "clear and recreate" safe during signal callbacks.- Why "clear-and-rebuild on every refresh" beats "diff existing children against new data" for lists with under a hundred entries.
How it applies
- Designer-driven content. A new Blessing is one new
.tresinresources/upgrades/, dragged into theall_upgradesarray on the UpgradeList node. Zero code changes to display it. The same UpgradeList script handles three Blessings or thirty. - Modding seam. A mod that adds new Blessings drops
.tresfiles into a folder; a discovery pass populatesall_upgradesfrom the folder contents at startup; the list renders the modded content with no engine code changes. - Save-aware re-render. When M7 loads a save, every owned upgrade is restored. The list re-renders from scratch on load, automatically hiding upgrades the player already owns (per M4.3's purchase guard) and showing locked ones at appropriate thresholds (per M4.4).
- Localization without retesting. Each row's text is computed from the
UpgradeData.display_nameanddescriptionfields per render. Switching language at runtime triggers a refresh; every row's text re-derives from the (now-localized) data. No row-by-row text update; one re-render covers it. - QA observability. A QA tester opening the Remote tab sees the live
UpgradeListnode with its current children — N rows for the N currently displayable upgrades. Each child Button's text reflects the data; the tester can right-click to inspect or call methods directly. Static-scene UI hides this kind of correspondence; dynamic UI surfaces it.
Concepts¶
Scene-time UI vs runtime UI¶
Up to M4 the textbook authored every UI node in the editor: TopBar, CounterLabel, HonorLabel, ContentArea. Each was a child added in the Scene dock at edit time and saved into main.tscn. The number of nodes was fixed at author time; the only runtime change is property values (Label.text).
An upgrade list cannot be authored this way. The number of upgrades is data-driven — three today, twelve next month, modded to fifty. Authoring fifty Buttons in the editor is wrong on every axis: tedious, fragile to data changes, repeats the same node configuration fifty times.
The alternative is runtime UI: a VBoxContainer is authored in the editor (the parent), but its children are add_child'd in code during _ready() based on the data. The parent is the empty stage; the children are produced by a function that reads the data and creates the appropriate node tree.
Example
You author one row in the editor: a Button with specific styling, an icon TextureRect, a description Label. To make twenty rows you'd duplicate it twenty times; to update the styling of all twenty, you'd edit each copy. The runtime alternative is one PackedScene file (upgrade_row.tscn) and a script that instantiates it N times. Edit the scene once; every instance receives the change. The author-time-vs-runtime distinction is also a one-edit-vs-many-edit distinction.
Button.new() versus PackedScene.instantiate()¶
There are two ways to produce a row at runtime.
Button.new() (and analogues Label.new(), HBoxContainer.new()) constructs a fresh instance of one node type with default property values. The instance is detached — not yet in the scene tree. Properties are set in code (btn.text = "Click", btn.disabled = false), then add_child(btn) attaches it. This is the right tool for one-node rows: a Button, nothing else.
PackedScene.instantiate() schedules a node for deletion at the end of the current frame. The node is still in the tree, still callable, still emitting signals during the rest of the frame; only at end-of-frame is it removed from the tree, its _exit_tree() called, its memory freed.
This loop iterates a snapshot of children, queues each for free. The children remain in the tree until end-of-frame. If a signal handler calls this loop, the rest of the handler can still reference any of these nodes safely; only the next frame finds them gone.
The non-deferred sibling, free(), deletes immediately. If anything on the call stack references the freed node afterward — even just the engine's signal-emit machinery iterating its connected listeners — the result is a use-after-free crash. queue_free() is the safe default; reserve free() for non-Godot objects (custom RefCounted) or for nodes you are certain nothing else references.
Example
A signal handler iterates the upgrade list and calls free() on each child mid-iteration. The next iteration step accesses the array index of an already-freed node — crash, or worse, silent corruption. The same loop with queue_free() is safe: the children survive the iteration, the array stays valid, the actual frees run after the handler returns. The choice is "delete now, hope nothing breaks" vs "delete soon, no surprises." Surface the second option as the default.
Clear-and-rebuild vs diff-and-update¶
When the upgrade list changes — a new threshold reached, an upgrade purchased and removed — the question is "regenerate every row, or surgically update?" The textbook chooses regenerate.
The diff approach maintains a parallel state ("which rows currently exist, what data they bound to") and computes a minimal patch ("add this row, remove that row, update this Label"). It is fewer node operations per change, at the cost of carrying the diff logic.
The regenerate approach calls queue_free() on every existing row and add_child for every visible upgrade in the data. It is more node churn but trivially correct: any state change in the data produces a fresh render that reflects the data exactly.
For under a hundred rows, regenerate wins — the per-frame cost is microseconds, well under any frame budget. For thousands of rows you would need virtualization (only render the visible window) and incremental updates. M4.2's upgrade list will never have more than a few dozen entries; regenerate is correct and simple.
Example
A purchase removes a row from the list. With diff, you locate the specific row, free it, animate the remaining rows up to fill the gap, update internal indices. With regenerate, every row is freed and recreated; the new render is one row shorter and the layout shifts. The visible result is identical (instant snap to new layout); the implementation is one third the code.
Walkthrough¶
You will perform these in your own Godot editor. Coming in, two .tres files exist (blessing_of_might.tres, sermon_of_dawn.tres) and the main scene's ContentArea is an empty HSplitContainer.
- Open
scenes/main.tscn. In the Scene dock, right-clickContentArea(theHSplitContainer) → Add Child Node → searchVBoxContainer→ create. Rename toUpgradeList. TheHSplitContainernow has one child; the divider is invisible until a second child arrives. - Right-click
ContentAreaagain → Add Child Node → searchVBoxContainer→ create. Rename toBuildingList. (M5 will populate this; for M4 it stays empty.) The split now has two children; a draggable divider appears in the viewport. - Right-click
UpgradeList→ Attach Script. Path:res://scripts/upgrade_list.gd. Click Create. The script editor opens. - Replace the body. The full code-fragment ceiling for this chapter, split into three logical pieces:
The
@exportdeclares an Inspector-editable array ofUpgradeDatareferences. The Inspector renders this as a list with+to add a slot and a drag target per slot — drop a.tresfrom the FileSystem dock onto a slot to populate it. - Add the refresh method that drives the rebuild:
Two loops in sequence: first frees existing children, then creates fresh rows. The
func refresh() -> void: for child in get_children(): child.queue_free() for upgrade in all_upgrades: add_child(_create_row(upgrade))for child in get_children()snapshot is whatqueue_freequeues against; the new rows attach in the second loop. End-of-frame, the queued frees run; the new rows are already in place. - Add the row factory:
A
func _create_row(upgrade: UpgradeData) -> Button: var btn: Button = Button.new() btn.text = "%s — %s\nCost: %d" % [upgrade.display_name, upgrade.description, int(upgrade.cost)] return btnButtonwith two-line text: name + dash + description on the first line, cost on the second. The\nis a literal newline inside the format string. Nopressedconnection yet — M4.3 will add the purchase wiring. The button does nothing on click for now. - Connect refresh to
_readyand to relevant signals: For now the list refreshes only at startup. M4.4 will add a connection toLight.value_changedso the list refreshes when thresholds are crossed; for M4.2 the static refresh is enough to verify the rendering path. - Save (
Ctrl+S). Switch to the scene editor. SelectUpgradeListin the Scene dock. The Inspector shows the script'sAll Upgradesarray (currently empty). Click the array's+button twice — two empty slots appear. For each slot, drag the corresponding.tresfromresources/upgrades/(blessing_of_might.tresandsermon_of_dawn.tres) onto it. Both slots fill with<UpgradeData>references showing the file names. - Press
F5. The right side ofContentArea(or whereverUpgradeListlands depending on the split position) shows two stacked Buttons: Blessing of Might — Doubles your Light per click.\nCost: 50Sermon of Dawn — Doubles passive Light income.\nCost: 200Clicking either Button does nothing yet. The list rendered correctly from data; M4.3 will wire the purchase.- Stop the game. As a verification, edit
blessing_of_might.tres'scostfield in the Inspector — change50.0to60.0. Save. Re-launch. The first row's text now readsCost: 60. The data drove the render; no script edit was needed.
Optional sanity check. Add a third
.tresto verify dynamic count handling. Right-clickresources/upgrades/→ New Resource → UpgradeData. Save asrelic_of_truth.tres. Setid,display_name,costto plausible values (relic_of_truth,Relic of Truth,500.0). Drag it into the third slot ofUpgradeList'sall_upgradesarray. F5 — the list shows three rows. Remove the third entry from the array. F5 — two rows again. The list renders whatever the array contains; nothing else changes.
Self-check quiz¶
Q1 — You forget the for child in get_children(): child.queue_free() loop in refresh(). After three calls to refresh(), what does UpgradeList contain?
A. Two rows, repeatedly overwritten in place.
B. Six rows — two from each refresh, accumulating in tree order.
C. Two rows — add_child detects duplicates by data reference and skips re-adds.
D. A runtime error: "child already in tree."
Reveal answer
B — six rows accumulate. Each call to refresh() adds two new Buttons to UpgradeList without removing the previous ones. After three calls there are six children. The add_child call does not check for duplicates by data; it adds whatever node it is given. The clear-loop is mandatory for "rebuild from scratch" semantics. A imagines an in-place update that does not exist. C fabricates content-aware deduplication. D would happen only if you tried to add the same node instance twice (a node can have only one parent at a time) — but each _create_row call returns a fresh Button.new(), no instance reuse, no error.
Q2 — A signal handler calls free() (not queue_free()) on every child of a Container in a loop. Why is this dangerous?
A. free() skips the engine's notification system; signals do not unhook properly.
B. free() deletes immediately; if anything on the call stack still references the freed node — including the engine's signal-emit machinery — the result is a use-after-free crash.
C. free() only works on RefCounted objects, not Nodes.
D. free() requires the node to be detached from the tree first; calling it on a tree-attached child is a no-op.
Reveal answer
B — free() is immediate, with no deferred safety net. The handler is mid-emit; the engine's signal system has the listener's frame on its stack. Calling free() on a node that the emit machinery still references is a use-after-free. queue_free() defers the actual deletion to end-of-frame, by which time all signal callstacks have unwound. The textbook's universal default is queue_free(); free() is reserved for non-Godot objects you constructed yourself with no engine references. A confounds different mechanisms — free() does run notifications. C is wrong: free() works on Nodes; for RefCounted, the right call is unreference() or letting the last reference drop. D is wrong: free() removes from tree as part of the delete, regardless of attachment.
Q3 — Your row is a Button with name, description, cost, and an icon TextureRect — four nodes total per row. You implement _create_row with Button.new() + Label.new() + TextureRect.new() + ... and parent them in code. Twenty rows in, the code is brittle. What is the better tool, and why?
A. Button.new() is correct — keep going. Twenty repetitions are fine in code.
B. Author a upgrade_row.tscn in the editor with the four-node structure; preload it; instantiate() per row.
C. Use @onready to grab existing rows from the scene; do not build them in code.
D. Use a RichTextLabel to combine all four into one node.
Reveal answer
B — author the row as a PackedScene, instantiate per use. Multi-node UI elements belong in .tscn files where the structure is editable visually and the script is one-per-scene rather than one-per-node-creation. instantiate() produces a fresh tree per call; bindings via @onready inside the row's script attach references at construct time. The author-once-instantiate-many pattern scales to dozens of rows without code growth. A is the wrong direction — repeating multi-node construction in code is the antipattern this question is designed to identify. C confounds static scene authoring with dynamic data — @onready only finds nodes that exist at script-load time, but data-driven rows do not exist until the array is populated. D is a real Node but elides the structural editing the row needs (icon position, text styling); it doesn't solve the underlying multi-node-row problem.
Integration question¶
Q4 — open
UpgradeList extends VBoxContainer and uses add_child to populate runtime children. From M1.4 you know that a VBoxContainer lays out its children top-to-bottom by size flags. From M4.2 you know that _create_row returns a Button with default size_flags_vertical = SIZE_FILL. Predict what happens visually if all_upgrades contains twenty entries and the UpgradeList is given a fixed vertical extent of 300 px. Which container property would let you scroll the overflow?
Reveal expected answer
Twenty Buttons each at their minimum height (around 30–40 px depending on theme) totals 600–800 px of content — exceeds the 300 px vertical extent. Because VBoxContainer does not clip, scroll, or size-down its children, the children render past the bottom edge of the container; the bottom rows are visually clipped by whatever ancestor has clipping enabled (typically Control.clip_contents = true somewhere up the tree, or just visually offscreen if no clip is set). The fix is wrapping UpgradeList in a ScrollContainer: ContentArea > ScrollContainer > UpgradeList(VBoxContainer). ScrollContainer clips its child to its own rect and renders scroll bars when the child's preferred size exceeds the container's rect. The VBoxContainer continues laying out children at their natural heights; the ScrollContainer wraps the result with overflow handling. The deeper architectural property: containers are composable. Each container has one job (VBoxContainer stacks vertically, ScrollContainer clips and scrolls). Combining them produces "vertically stacked, scrollable list." Building a custom scrolling-VBox in one node would conflate two concerns; using two existing containers stacked keeps both replaceable.
Glossary¶
Glossary
queue_free()- A method on every
Nodethat requests deletion at the end of the current frame. Safe to call during signal callbacks,_process,_ready. The engine batches frees and runs them when no callstack is active. Distinct fromfree(), which deletes immediately and risks use-after-free. Button.new()/Node.new()constructor- The constructor pattern for any Node subclass — produces a fresh instance with default property values, not yet attached to any tree. Set properties, then
add_child()to attach. Use for simple single-node UI. PackedScene.instantiate()- A method on a
PackedSceneresource that produces a fresh node tree matching the scene's authored structure. Each call creates a new instance — children, properties, scripts, signal connections, all replicated. Use for any UI element that has more than one node. - dynamic UI / runtime UI
- UI whose children are created in code at runtime rather than authored as fixed nodes in a
.tscnat edit time. Right when the number of children is data-driven; wrong when the children are fixed and known in advance.