Skip to content

Building Row Scene + Cost Scaling

What you'll learn

  • The PackedScene workflow: author a multi-node row once in the editor as building_row.tscn, then instantiate() per building at runtime.
  • How @export var building: BuildingData plus a refresh method lets the row re-render its labels (name, count, cost) from current state on every relevant signal.
  • The %UniqueName access pattern — child nodes marked "Access as Unique Name" in the Scene dock are reachable as %CountLabel from the row's script, even if the tree depth changes.
  • How the M4.2 dynamic-list pattern composes with PackedScene: BuildingList iterates all_buildings, instantiates one row per entry, sets row.building = data, calls add_child.
  • Why every row connects to Tick.tick (or resource_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.tscn once. 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.gd script 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, BuildingList re-instantiates rows from all_buildings and each row reads its count from _buildings. Rows do not need to be saved; they re-derive from _buildings every 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_name reads 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: BuildingDatainstance configuration

Each row needs to know which BuildingData it represents. The pattern uses an @export field:

extends HBoxContainer

@export var building: BuildingData

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.

  1. 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 an HBoxContainer. Rename root to BuildingRow.
  2. Save the scene as scenes/building_row.tscn (Ctrl+S → navigate to scenes/ → name building_row.tscn).
  3. Add four children of BuildingRow. Right-click BuildingRow → Add Child Node → search and create:
  4. Label, rename to NameLabel. In Inspector, set Text to Name, Size Flags Horizontal to Expand Fill (so the name takes the available space).
  5. Label, rename to CountLabel. Set Text to × 0.
  6. Label, rename to CostLabel. Set Text to Cost: 0.
  7. Button, rename to BuyButton. Set Text to Buy.
  8. Mark the three labels and the button as unique-name accessible. In the Scene dock, right-click NameLabelAccess 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 for CountLabel, CostLabel, BuyButton. The Scene dock should now show four nodes with % badges.
  9. Right-click BuildingRow (the root) → Attach Script → save as res://scripts/building_row.gd. The script editor opens.
  10. Replace the body. The full code-fragment ceiling for this chapter, in two pieces:
    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 = %BuyButton
    
    Six declarations: the exported building (set by the list at instantiate time) and four child references via %UniqueName.
  11. 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_purchased and purchase_building and get_building_count are not yet defined on GameState — M5.3 adds them. For now, expect the script to error on _ready referencing missing methods. The error tells you M5.3 is the next step. Save anyway (Ctrl+S).
  12. Save the row scene (Ctrl+S again on building_row.tscn).
  13. Author the BuildingList script. Open main.tscn, select BuildingList in the Scene dock (the second VBoxContainer you added in M4.2), right-click → Attach Scriptres://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)
    
  14. Populate BuildingList's all_buildings. Select BuildingList in the Scene dock. In the Inspector, click the + on All Buildings three times — three empty slots. Drag initiate.tres, aspirant.tres, adept.tres from the FileSystem dock onto the three slots in order. Save the scene.
  15. 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 _ready connects to GameState.building_purchased (a signal not declared until M5.3) and the row will error on _ready with "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). Click TrainButton to 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() in building_row.gd: print("refresh ", building.id). Run, click Train. Each Light increment fires resource_changed, which fires _refresh() on every row. The Output panel shows refresh initiate per 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 (.tscn or .scn). The result of opening a .tscn in the editor; preload("res://scenes/foo.tscn") returns a PackedScene. instantiate() on a PackedScene produces 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 %NodeName regardless of tree depth. Use for stable references to scene-internal nodes that survive tree-shape edits. Distinct from $Path which encodes the literal tree path.
configure-before-attach
The discipline of setting an instantiated row's @export fields before calling add_child. add_child triggers _ready, which reads the fields; setting them after means _ready ran with default state.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through what happens during instantiate() on building_row.tscn — show me the call sequence (constructor, @onready resolution, _ready) and at which point each runs.` - `Show me how to convert M4.2's Button.new() row into a PackedScene — what file structure changes, what code changes.` - `Re-explain %UniqueName using a CSS id selector analogy — same use case, what's similar, what differs.`