Building Row Scene + Cost Scaling¶
What you'll learn
- The
PackedSceneworkflow: author a multi-node row once in the editor asbuilding_row.tscn, theninstantiate()per building at runtime. - How
@export var building: BuildingDataplus a refresh method lets the row re-render its labels (name, count, cost) from current state on every relevant signal. - The
%UniqueNameaccess pattern — child nodes marked "Access as Unique Name" in the Scene dock are reachable as%CountLabelfrom the row's script, even if the tree depth changes. - How the M4.2 dynamic-list pattern composes with PackedScene:
BuildingListiteratesall_buildings, instantiates one row per entry, setsrow.building = data, callsadd_child. - Why every row connects to
Tick.tick(orresource_changed) for label refreshes — count changes are signal-driven, not poll-driven.
How it applies
- Row design lives in the editor. A designer or UI engineer adjusts the row's layout (icon size, font, padding) by editing
building_row.tscnonce. Every instance picks up the change. Editing twenty rows in the editor is unthinkable; editing one scene is routine. - Independent script per row. Each instance has its own
building_row.gdscript and its own_ready(). Per-row state — last displayed cost, animation timers, hover effects — lives on the row, not in a global table. The encapsulation falls out of the PackedScene model for free. - Save independence. The row UI does not own state. The save format records
_buildings: Dictionary[String, int]; on load,BuildingListre-instantiates rows fromall_buildingsand each row reads its count from_buildings. Rows do not need to be saved; they re-derive from_buildingsevery refresh. - Animation hooks. A row animating "+1 Initiate" on purchase lives entirely in
building_row.gd's_on_purchased(data)handler. The animation does not coordinate with sibling rows; it does not emit signals; it lives and dies with its row instance. M8 builds on this for animated counters. - Locale changes. When the player switches language,
display_namereads through a translation function. Each row's_refresh()call re-reads the (now-localized) field; the UI updates without a single explicit re-translate call. The data drives the display every refresh; locale is just another input.
Concepts¶
PackedScene and the row-as-scene pattern¶
A PackedScene is the runtime form of a .tscn file — a frozen snapshot of a node subtree, with all properties, connections, and child scripts. preload("res://scenes/building_row.tscn") returns a PackedScene reference; calling .instantiate() on it produces a fresh node tree matching the authored structure. Each instantiate is independent: edits to the running instance don't affect the original or sibling instances.
The row pattern: open building_row.tscn in the editor; author the layout (HBoxContainer with NameLabel + CountLabel + CostLabel + BuyButton); attach a script that exposes @export var building: BuildingData; save. At runtime, code that wants twelve building rows preloads the scene once and instantiates twelve times.
Example
A row redesign — adding a "+1" pip indicator next to the BuyButton — is one edit: open building_row.tscn, drop the new node, save. Every existing instantiate site picks up the change automatically. The same redesign in code-built rows would require touching every _create_row method that constructed the old layout, plus testing that the new node lands in the right z-order. The PackedScene model serializes the design decision once; the code reads it.
@export var building: BuildingData — instance configuration¶
Each row needs to know which BuildingData it represents. The pattern uses an @export field:
The exported field is empty in the authored scene (no specific building bound). At instantiate time, code sets it: var row = ROW_SCENE.instantiate(); row.building = data; add_child(row). Inside the row's _ready(), the field is populated and the row can render itself.
The order matters: setting row.building before add_child is the convention. Once add_child runs, the row's _ready() fires, which reads building to populate the labels. Setting building after add_child would mean _ready() runs with building still null. _ready is too late to receive instance configuration; do it pre-add.
Example
A test scene needs a single Initiate row for visual review. The test instantiates building_row.tscn, sets row.building = preload("res://resources/buildings/initiate.tres"), adds it to the test container. The row renders correctly because _ready() runs after the field is set. If the test set row.building after add_child, _ready would have already run with building == null, the labels would be blank, and the test would falsely report a UI bug. Order discipline is part of the contract.
%UniqueName access¶
Child nodes of a scene can be marked "Access as Unique Name" in the Scene dock (right-click → Access as Unique Name, or check the box in the inspector). A unique-named node is reachable from any descendant of the scene's root via %NodeName — regardless of tree depth.
@onready var count_label: Label = %CountLabel
@onready var cost_label: Label = %CostLabel
@onready var buy_button: Button = %BuyButton
Compare to path-based: $HBoxContainer/CountLabel requires the script to know the tree shape. If a designer adds an intermediate MarginContainer wrapping the labels, every $path reference breaks. With %UniqueName, the path is irrelevant — the engine looks up "the node marked unique with this name in this scene" and returns it.
%UniqueName is for stable references to scene-internal nodes. Use it when a script's job is to coordinate children whose specific tree placement may change. Don't use it for cross-scene references (the lookup is scene-local; nodes outside the row's tree are invisible to it).
Example
The row designer wraps CountLabel in a Control for spacing. The path $HBoxContainer/CountLabel is now wrong — the actual path is $HBoxContainer/Control/CountLabel. With %CountLabel the script is unchanged: the lookup finds the Label by its unique-name tag, regardless of intermediate wrappers. The row's design can iterate without breaking the script.
Connecting refresh on relevant signals¶
Each row subscribes to signals that should trigger a re-render:
GameState.resource_changed— Light value changes mean affordability re-check; the BuyButton's disabled state may flip.GameState.building_purchased(M5.3) — count changes mean the row's count label and cost label update.
The _ready of building_row.gd connects both, then calls _refresh() once for the initial state. Each connection's handler is a one-liner that calls _refresh(); the refresh reads current state from GameState and re-renders.
func _ready() -> void:
GameState.resource_changed.connect(func(_n, _v): _refresh())
GameState.building_purchased.connect(func(_b): _refresh())
buy_button.pressed.connect(func(): GameState.purchase_building(building))
_refresh()
The connections discard the signal arguments — the row doesn't care which resource changed or which building was purchased; it just re-renders. M4.3's pattern of filtering on argument value (if name == "light") would be slightly more efficient (don't refresh on Honor changes), but for under twenty rows the cost is invisible and the simpler unconditional refresh is preferred.
Example
The player buys an Initiate. GameState.building_purchased.emit(initiate_data) fires. Every row's lambda runs _refresh(). The Initiate row reads _buildings["initiate"] (now 1), computes get_cost(1) = 17.25, updates its labels. The Aspirant row also runs _refresh() — reads _buildings["aspirant"] (still 0), computes get_cost(0) = 100, labels unchanged. The over-fire is one extra label assignment per untouched row; cheap.
BuildingList composition with PackedScene¶
The list-level script is structurally the same as UpgradeList from M4.2. The difference is _create_row now uses PackedScene instead of Button.new():
extends VBoxContainer
const BUILDING_ROW: PackedScene = preload("res://scenes/building_row.tscn")
@export var all_buildings: Array[BuildingData] = []
func _ready() -> void:
refresh()
func refresh() -> void:
for child in get_children():
child.queue_free()
for data in all_buildings:
if GameState.get_resource(data.unlock_resource) < data.unlock_threshold:
continue
var row = BUILDING_ROW.instantiate()
row.building = data
add_child(row)
The row's own _refresh handles per-row updates; the list's refresh handles structural updates (locked → available, building list edits). The two responsibilities are split: list owns "which rows exist," row owns "what does my data look like right now."
Example
A locked Aspirant becomes visible when Light crosses 50. The list's connection to resource_changed runs refresh, which re-evaluates threshold gates and now includes Aspirant in the row creation loop. The instantiated Aspirant row's _ready runs, reads its own state, displays "Aspirant × 0, Cost: 100." All inputs flow through the same refresh cascade; no special "unlock" event needs separate handling.
Walkthrough¶
You will perform these in your own Godot editor.
- Create the row scene. Scene → New Scene (top menu, or the "+" tab next to the open scene). In the dialog, choose 2D Scene is wrong — choose Other Node, search
HBoxContainer, click Create. The new scene's root is anHBoxContainer. Rename root toBuildingRow. - Save the scene as
scenes/building_row.tscn(Ctrl+S→ navigate toscenes/→ namebuilding_row.tscn). - Add four children of
BuildingRow. Right-clickBuildingRow→ Add Child Node → search and create: Label, rename toNameLabel. In Inspector, setTexttoName,Size Flags HorizontaltoExpand Fill(so the name takes the available space).Label, rename toCountLabel. SetTextto× 0.Label, rename toCostLabel. SetTexttoCost: 0.Button, rename toBuyButton. SetTexttoBuy.- Mark the three labels and the button as unique-name accessible. In the Scene dock, right-click
NameLabel→ Access as Unique Name (or check the small icon in the Scene dock that toggles the % marker — Godot 4.6 shows a small "%" badge next to unique-named nodes). Repeat forCountLabel,CostLabel,BuyButton. The Scene dock should now show four nodes with%badges. - Right-click
BuildingRow(the root) → Attach Script → save asres://scripts/building_row.gd. The script editor opens. - Replace the body. The full code-fragment ceiling for this chapter, in two pieces:
Six declarations: the exported
extends HBoxContainer @export var building: BuildingData @onready var name_label: Label = %NameLabel @onready var count_label: Label = %CountLabel @onready var cost_label: Label = %CostLabel @onready var buy_button: Button = %BuyButtonbuilding(set by the list at instantiate time) and four child references via%UniqueName. - Add the lifecycle and refresh:
func _ready() -> void: GameState.resource_changed.connect(func(_n, _v): _refresh()) GameState.building_purchased.connect(func(_b): _refresh()) buy_button.pressed.connect(func(): GameState.purchase_building(building)) _refresh() func _refresh() -> void: var count: int = GameState.get_building_count(building.id) name_label.text = building.display_name count_label.text = "× %d" % count cost_label.text = "Cost: %d" % int(building.get_cost(count)) buy_button.disabled = GameState.get_resource("light") < building.get_cost(count)building_purchasedandpurchase_buildingandget_building_countare not yet defined on GameState — M5.3 adds them. For now, expect the script to error on_readyreferencing missing methods. The error tells you M5.3 is the next step. Save anyway (Ctrl+S). - Save the row scene (
Ctrl+Sagain onbuilding_row.tscn). - Author the BuildingList script. Open
main.tscn, selectBuildingListin the Scene dock (the secondVBoxContaineryou added in M4.2), right-click → Attach Script →res://scripts/building_list.gd. Replace body:extends VBoxContainer const BUILDING_ROW: PackedScene = preload("res://scenes/building_row.tscn") @export var all_buildings: Array[BuildingData] = [] func _ready() -> void: GameState.resource_changed.connect(func(_n, _v): refresh()) refresh() func refresh() -> void: for child in get_children(): child.queue_free() for data in all_buildings: if GameState.get_resource(data.unlock_resource) < data.unlock_threshold: continue var row = BUILDING_ROW.instantiate() row.building = data add_child(row) - Populate
BuildingList'sall_buildings. SelectBuildingListin the Scene dock. In the Inspector, click the+onAll Buildingsthree times — three empty slots. Draginitiate.tres,aspirant.tres,adept.tresfrom the FileSystem dock onto the three slots in order. Save the scene. - Do not press F5 to test the Buy flow at end-of-M5.2 — it will crash. This chapter ships the row UI; the methods the row references (
GameState.purchase_building,GameState.building_purchased,GameState.get_building_count) are M5.3's deliverable and do not exist yet. The script's_readyconnects toGameState.building_purchased(a signal not declared until M5.3) and the row will error on_readywith "signal not found." If you want to see the UI render without functionality, you can launch briefly — the game launches, the BuildingList shows one row (Initiate × 0, Cost: 15, Buy button disabled because Light = 0; Aspirant and Adept locked behind thresholds 50 and 500). ClickTrainButtonto grow Light; as Light crosses 15, the Buy button enables — clicking it crashes because the purchase method does not exist. The textbook deliberately ships this failing state so the learner can observe the unwired dependency and see exactly which methods M5.3 must define. Stop the game without clicking Buy.
Optional sanity check. Add a print line at the top of
_refresh()inbuilding_row.gd:print("refresh ", building.id). Run, click Train. Each Light increment firesresource_changed, which fires_refresh()on every row. The Output panel showsrefresh initiateper click — confirms the signal-driven refresh path is wired. With three rows visible, three prints per click; remove the print after verifying.
Self-check quiz¶
Q1 — You instantiate a row, set row.building = data, then call add_child(row). The order is reversed: add_child(row) first, then row.building = data. What does the row display?
A. The data is set; the row renders correctly because _ready runs only when the scene tree is fully ready.
B. _ready ran on add_child, which read building == null, the labels reading "× 0" and "Cost: 0" with empty name. Setting building afterward does not re-trigger _ready or _refresh.
C. The row crashes on add_child because building is null.
D. The engine queues _ready until building is set; the row remains blank until then.
Reveal answer
B — _ready runs on add_child with whatever state was already set, then doesn't re-run. add_child triggers the row's _ready immediately (after the row enters the scene tree). At that moment building is null; _refresh reads null.id and likely crashes (a real bug), or at best displays placeholders. Setting building after add_child updates the field but _ready has already run; no re-render happens unless something else triggers _refresh. The discipline is "configure before attach": set every @export and any state the row depends on before add_child so _ready sees a fully-initialized row. A is wrong about timing — _ready runs on tree entry, not on full readiness. C is correct that null-deref is likely; the deeper answer is the order issue. D fabricates queued _ready semantics.
Q2 — The row designer adds a MarginContainer wrapping CountLabel. The script's @onready var count_label: Label = %CountLabel continues to work. Why?
A. %UniqueName looks up the node by its unique-name tag, regardless of the intermediate tree shape.
B. Godot caches all @onready references at scene load; subsequent edits don't invalidate them.
C. MarginContainer is transparent to lookups; only Containers in LayoutContainer family count.
D. The script automatically updates its references on save.
Reveal answer
A — unique-name lookup is shape-independent. A node marked "Access as Unique Name" in the Scene dock is reachable from any descendant of the scene root via %NodeName, regardless of how deep or what wrappers exist between the script and the target. The lookup is by tag, not by path. This is the chief value of unique-names over $Path references — the script survives layout refactors. B fabricates caching behavior. C imagines a container hierarchy that doesn't exist. D imagines auto-update of script references. The lesson: unique-names trade a small editor-time markup (the % tag) for resilience against tree-shape changes.
Q3 — Every row connects to resource_changed and calls _refresh() on every emit. With three rows visible, the Light tick at 10 Hz produces how many _refresh calls per second, and how many of them produce a visible label change?
A. 30 per second; about 10 produce visible change (the cost label updates per-tick, but only when the integer cast changes).
B. 30 per second; on most ticks, all three labels' computed text matches the previous text — re-assigning the same string is a Godot-engine no-op, no redraw.
C. 30 per second; redraws happen anyway because Label.text = always invalidates.
D. 0 per second; the engine deduplicates Label.text writes that match the existing value.
Reveal answer
A — full refresh fires; only visible changes redraw. Three rows times 10 Hz means 30 _refresh() calls per second. Each call assigns to four Label.text properties (name, count, cost) and one Button.disabled field. Godot's Label.text setter does check for equality before invalidating — assigning the same string is approximately free at the redraw level (no font rasterization, no layout pass). The cost-int formatting ("%d" % int(value)) produces a new string only when the integer truncation changes, which is roughly once per tenth of the way to the next whole; depending on light_per_second the actual redraw cadence is about one Label change per row per integer crossing. The lesson: over-firing refresh is acceptable for small lists because the engine's setters are smart about no-op writes. For larger lists (twenty buildings, four labels each, fifty refreshes per second = 4000 setter calls per second), the M5 chapter's pitfall list flags throttling as a real concern. B is too optimistic about specific equality-check behavior; the engine does check, but _refresh() itself still runs the format calls. C is too pessimistic — Godot does dedupe at the Label level. D imagines engine-wide deduplication that doesn't exist.
Integration question¶
Q4 — open
Compare M4.2's _create_row(upgrade) (which produces a Button.new() per upgrade) with M5.2's PackedScene-based _create_row (which instantiate()s building_row.tscn). What does each design optimize for, and what does each give up?
Reveal expected answer
M4.2's Button.new() optimizes for minimal scaffolding: a one-node row needs no scene file, no separate script, no PackedScene preload. The _create_row method is six lines; the row is constructed inline; the row's behavior (the pressed lambda) is captured at construction time. The trade-off: every property of the row lives in code (btn.text, btn.disabled), so a redesign requires editing the script. The one-node case fits this trade well. M5.2's PackedScene optimizes for multi-node design ergonomics: the row is four nodes (name, count, cost, button) with their own layout, theme, and per-row script. Authoring this in code per _create_row call is fifty-plus lines of repetitive setup; authoring it once as a .tscn is one editor session, then instantiate() per use. The row's design lives in the editor where it belongs; the row's behavior lives in building_row.gd, attached to the scene. The trade-off: more files (scene + script) and the configure-before-attach discipline (set @export fields before add_child). The deeper architectural lesson: PackedScene is the right tool for any row with more than one or two nodes; Button.new() is the right tool for one-node simple cases. The cost-of-PackedScene curve is flat: it's roughly the same effort whether the row has two nodes or twelve. The cost-of-code-construction curve scales linearly with node count. The crossover is somewhere around three nodes, which the textbook crosses going from M4.2 (one node, code) to M5.2 (four nodes, scene).
Glossary¶
Glossary
PackedScene- Godot's resource type representing a scene file (
.tscnor.scn). The result of opening a.tscnin the editor;preload("res://scenes/foo.tscn")returns aPackedScene.instantiate()on aPackedSceneproduces a fresh node tree matching the scene's authored structure. %UniqueName- A scene-local lookup: a child Node marked "Access as Unique Name" in the Scene dock can be reached from any descendant via
%NodeNameregardless of tree depth. Use for stable references to scene-internal nodes that survive tree-shape edits. Distinct from$Pathwhich encodes the literal tree path. - configure-before-attach
- The discipline of setting an instantiated row's
@exportfields before callingadd_child.add_childtriggers_ready, which reads the fields; setting them after means_readyran with default state.