Skip to content

Building Purchase Flow

What you'll learn

  • The repeatable purchase pattern: every successful purchase increments a count, deducts the current cost (which scales with count), and refreshes derived state.
  • The _buildings: Dictionary[String, int] data structure — id → count — and why this is the smallest possible state shape for a building economy.
  • How purchase_building differs from purchase_upgrade (M4.3): no one-time guard, scaled cost via get_cost(count), count increment instead of ownership flag.
  • Why the cost lookup happens before the deduction — both for reading the current scaled price and for the guard check.
  • The composition with M3.2's recompute-on-change discipline: every successful purchase calls _recalculate_income() to update cached _income_rates.

How it applies

  • Bulk purchase ergonomics. A "buy ×10" button calls purchase_building ten times in a loop. Each call computes the current cost from the current count, deducts, increments. The cost ramps within the loop — buying 10 at once is more expensive than 10 × first-cost. The math is correct because the data is read fresh each iteration, not pre-computed.
  • Stale-cost prevention. Reading the cost via building.get_cost(current_count) inside the guard ensures the price always reflects the live state. Caching the cost from M5.2's UI refresh would mean a stale cost during the small window between _refresh() and purchase_building. The recompute-from-source pattern avoids this class of bug.
  • Save/load round-trip. _buildings serializes as {"initiate": 47, "aspirant": 12, "adept": 3} — a few hundred bytes regardless of session length. On load, _recalculate_income() reconstructs _income_rates by iterating _buildings × _all_buildings_data. The save format stores ownership; the derived state recomputes.
  • Refund scenarios. A "sell building" feature is _buildings[id] -= 1 plus a partial refund (get_cost(count - 1) × 0.5) plus _recalculate_income(). The same shape as purchase, with sign flipped on the count and cost. The single source of truth (_buildings) makes inverse operations symmetric.
  • Cheats and modding. Setting _buildings["initiate"] = 999 from the Remote tab (or a mod) instantly grants 999 Initiates without the cost scaling — they bypassed the guard. This is the right semantics for cheats: the cheat operates on state, not on the transaction. The next _recalculate_income() pass picks up the new count and updates passive income.

Concepts

Repeatable purchase versus one-time purchase

M4.3's purchase_upgrade checked _purchased_upgrades.has(id) to refuse re-purchases. The M5 equivalent is the absence of that check — buildings can be bought as many times as the player can afford.

func purchase_building(data: BuildingData) -> bool:
    var count: int = get_building_count(data.id)
    var cost: float = data.get_cost(count)
    if get_resource("light") < cost:
        return false
    add_to_resource("light", -cost)
    _buildings[data.id] = count + 1
    _recalculate_income()
    building_purchased.emit(data)
    return true

The structure is the same as M4.3's purchase guard: validate, deduct, mutate, recompute, emit, return. The differences are: (1) cost is computed dynamically from the current count via get_cost(count), (2) the mutation is count + 1 rather than setting a flag, (3) the recompute call is required (M3.2's discipline) because building counts feed into _income_rates.

Example

A player owns 30 Initiates and clicks Buy. count = 30; cost = 15 × 1.15^30 ≈ 993. Light is at 1000; the guard passes. add_to_resource("light", -993) brings Light to 7. _buildings["initiate"] = 31. _recalculate_income() walks _buildings, sums 30 × 0.1 → 3.1 (oh wait, it's now 31 × 0.1 → 3.1 Light per second). The signal fires; the row's _refresh() runs; the count label flips to "× 31"; the cost label flips to pow(1.15, 31) × 15 ≈ 1141. The Buy button reads Light < 1141 and disables.

_buildings: Dictionary[String, int] — minimal state

The entire building economy's state is _buildings: Dictionary[String, int]. id → count. Five entries for the five tiers; one save-format entry per tier; one source of truth for the entire system.

_buildings differs from M4.3's _purchased_upgrades: Dictionary[String, UpgradeData] in two ways:

  • The value is int (count), not UpgradeData (reference). Counts compose with multiplication; references compose with set membership. Different math, different storage.
  • Missing keys default to zero, not "not owned." get_building_count(id) returns _buildings.get(id, 0). A building that has never been purchased has no entry; the lookup returns 0; downstream code treats "never purchased" and "purchased zero times" identically.

Example

A new building is added to the project — Sunwalker. Players who started before Sunwalker existed have no "sunwalker" key in their save's _buildings. On load, _buildings.get("sunwalker", 0) returns 0; the row renders with count 0; the player can purchase normally. No save migration code; the default-on-missing semantics handle the schema evolution.

Cost lookup happens before deduction

The order in purchase_building is: read count, compute cost, validate, deduct, increment count, recompute, emit. The cost computation reads the current count (before the increment); the deduction uses that cost; the increment happens after.

var count: int = get_building_count(data.id)
var cost: float = data.get_cost(count)
if get_resource("light") < cost:
    return false
add_to_resource("light", -cost)
_buildings[data.id] = count + 1

Reordering breaks the math:

  • If count + 1 happened before get_cost: the cost would scale to the post-purchase count, charging the player for the wrong unit. Buying their first Initiate would cost 15 × 1.15^1 ≈ 17.25 instead of 15 × 1.15^0 = 15.
  • If the deduction happened after the count increment but with the original cost: the count is wrong during any code that runs between the two, opening a window where derived state (income) is computed against the new count but the cost wasn't paid yet.

The order matters; the explicit reading of count into a local variable preserves the value across mutations.

Example

A debug line prints count and cost between the var count line and the deduction. Output: count=30, cost=993.36. Then add_to_resource("light", -993.36). Then _buildings["initiate"] = 31 (the increment uses the captured count + 1, not a re-read). The two count values across the method (the read for cost, the implicit + 1 for the write) are consistent because both derive from the same captured local. Ad-hoc reorderings break this consistency.

Composition with _recalculate_income

Every successful purchase calls _recalculate_income() to refresh the _income_rates cache (M3.2 / M3.3). The recompute walks _buildings, multiplies each count by the corresponding base_output × tick_multiplier × upgrade-multipliers, and writes the per-resource sum into _income_rates.

func _recalculate_income() -> void:
    var totals: Dictionary[String, float] = {"light": 0.0, "honor": 0.0}
    for id in _buildings:
        var count: int = _buildings[id]
        var data: BuildingData = _building_lookup[id]
        totals["light"] += data.base_output * count
    totals["light"] *= tick_multiplier
    _income_rates = totals

The _building_lookup: Dictionary[String, BuildingData] is a project-startup populated map from id to the BuildingData reference. M5.4 will plumb it.

The recompute is per-purchase, not per-tick (M3.2's discipline). One purchase = one recompute. The tick handler reads cached _income_rates directly with no math.

Example

After the 31st Initiate purchase, recompute runs: totals["light"] = 31 × 0.1 = 3.1. Multiplied by tick_multiplier = 2.0 (Sermon was bought earlier): 6.2. _income_rates["light"] = 6.2. The next 0.1s tick adds 6.2 × 0.1 = 0.62 Light. The CounterLabel updates twice per second visibly (every two ticks the integer increments). The recompute's cost — one Dictionary walk over five entries — is microseconds; called once per purchase, negligible.

Walkthrough

You will perform these in your own Godot editor. Coming in, M5.2's BuildingRow script fails on _ready because GameState.purchase_building, building_purchased, and get_building_count do not exist.

  1. Open scripts/game_state.gd. Add the building signal and state below the existing signal upgrade_purchased:
    signal building_purchased(building: BuildingData)
    
    var _buildings: Dictionary[String, int] = {}
    var _building_lookup: Dictionary[String, BuildingData] = {}
    
    _buildings is the count map. _building_lookup is the id-to-reference map populated at startup; M5.4 will populate it. For now declare both empty.
  2. Add the count accessor:
    func get_building_count(id: String) -> int:
        return _buildings.get(id, 0)
    
    .get(id, 0) returns 0 for missing keys. M5.2's _refresh() already calls this method.
  3. Add the purchase guard. The full code-fragment ceiling for this chapter, in one fragment:
    func purchase_building(data: BuildingData) -> bool:
        var count: int = get_building_count(data.id)
        var cost: float = data.get_cost(count)
        if get_resource("light") < cost:
            return false
        add_to_resource("light", -cost)
        _buildings[data.id] = count + 1
        _recalculate_income()
        building_purchased.emit(data)
        return true
    
    Eight lines. Same shape as M4.3's purchase_upgrade: read state, validate, mutate, recompute, emit. The differences from M4.3 are: dynamic cost via get_cost(count), increment-not-flag mutation, mandatory _recalculate_income() call.
  4. Add _recalculate_income. This method existed conceptually in M3.2 (for Light._generators); the multi-resource version is now in GameState:
    func _recalculate_income() -> void:
        var totals: Dictionary[String, float] = {}
        for resource_name in _resources:
            totals[resource_name] = 0.0
        for id in _buildings:
            var count: int = _buildings[id]
            if not _building_lookup.has(id):
                continue
            var data: BuildingData = _building_lookup[id]
            totals["light"] += data.base_output * count
        totals["light"] *= tick_multiplier
        _income_rates = totals
    
    The double loop initializes every known resource's rate to zero (so dropping a building correctly zeroes its contribution), then walks _buildings to sum per-tier output. The final * tick_multiplier applies the M4.3 sermon-stack. _building_lookup is checked with has(id) to skip ids that aren't yet registered. Save (Ctrl+S).
  5. Wire _building_lookup from BuildingList. Open scripts/building_list.gd. In _ready(), before the refresh() call, register every building in all_buildings to GameState's lookup:
    func _ready() -> void:
        for data in all_buildings:
            GameState._building_lookup[data.id] = data
        GameState.resource_changed.connect(func(_n, _v): refresh())
        GameState.building_purchased.connect(func(_b): refresh())
        refresh()
    
    The _building_lookup access is a leaky abstraction (the underscore-prefix says "private" but we're writing from outside) — M5.4 will refactor this into a register_building(data) method on GameState. For M5.3 the direct dict-write is clearest. Also added is a connection to building_purchased so the list refreshes (showing newly-unlocked tiers if any threshold was just crossed).
  6. Save scripts. Press F5. The Initiate row shows × 0, Cost: 15, Buy disabled (Light = 0). Click Train enough times to reach 15 Light. The Buy button enables. Click Buy. Light drops to 0; the count label reads "× 1"; the cost label reads "Cost: 17"; the Buy button disables. Click Train again to reach 17; Buy enables; click Buy; cost label reads "Cost: 19" (15 × 1.15^2 ≈ 19.84 truncated). The cost ramps as expected.
  7. Verify passive income works. With Light = 0 and Initiates = 2, wait. The CounterLabel ticks: ten ticks per second × 0.2 light_per_second (2 × 0.1) × tick_multiplier = 1.0 (no Sermon yet) × tick_delta = 0.1 = 0.02 Light per tick. After ten seconds you have 2.0 Light. After fifty seconds, 10.0. Slow growth at the start of the curve; this is correct for a fresh game.
  8. Test the recompute path. Buy a third Initiate (cost ≈ 23). Light drops to 0; income rate becomes 3 × 0.1 = 0.3 per second. After ten seconds, Light is at 3, the next Initiate (cost ≈ 26.4) is in reach. The exponential cost outruns linear income; the player must invest in a cheaper next-purchase per Light, which is what cost scaling is for. Stop the game.
  9. Buy multiple buildings of different tiers if the thresholds allow. With Light = 50, Aspirant unlocks (cost 100); grind to 100, buy Aspirant. Income jumps to (0.1 × current_initiate_count) + (1.0 × 1) = some_total. The single recompute correctly aggregates across tiers.
  10. Save the project (the project.godot was modified by the autoload registrations from earlier modules; no extra save needed).

Optional sanity check. From the Remote tab in a running game, set _buildings["initiate"] = 100 directly. The next resource_changed (any tick or click) will cause BuildingRow._refresh to run; the count label snaps to × 100. The cost label updates to 15 × 1.15^100 ≈ 17,617. Income stays at the previously-recomputed value because direct _buildings writes don't trigger _recalculate_income. Call GameState._recalculate_income() from the Remote tab REPL to refresh the income cache; the next tick's Light increment reflects the new count. The two paths — recompute-on-purchase (automatic) versus recompute-on-direct-write (manual) — exist; the first is the production path, the second is the cheat/test path.

Self-check quiz

Q1 — purchase_building reads count, computes cost = get_cost(count), validates, deducts, increments. Suppose you reorder so that _buildings[data.id] = count + 1 happens before the line var cost: float = data.get_cost(count) (with count still being the local read at the start of the method). The player's first Initiate purchase: what cost is charged, and what state is left if the guard fails?

A. 15.0 charged; if the affordability guard fails, count is incremented even though no Light was deducted. B. 17.25 charged because get_cost re-reads from _buildings. C. 15.0 charged; the engine detects the reorder and uses the pre-increment count for get_cost. D. The method crashes because _buildings[data.id] writes before validation completes.

Reveal answer

A — 15.0 charged, count incremented before validation. The captured local count (read at the top of the method) is 0. get_cost(0) computes 15.0, regardless of what was just written into _buildings. The bug is not the cost — that's correct. The bug is the order: _buildings[data.id] = count + 1 ran before the affordability check. If the player could not afford 15.0 Light, the guard would return false but the count would already be incremented. State has changed despite the transaction failing. B is wrong: the reorder doesn't re-read count; the local is captured. C imagines compiler magic that doesn't exist. D is wrong: the dictionary write succeeds; only the order is broken. The lesson: capture every value you'll need at the top of the method, validate before mutating, mutate only when validation passes.

Q2 — A 'buy ×10' button calls purchase_building ten times in a tight loop. The player has enough Light for the first eight purchases at the current scaled cost but not the ninth. What happens?

A. The first eight succeed; the ninth fails (returns false); the loop continues; the tenth checks affordability against the new count and may succeed if the player got more Light, otherwise fails too. B. All ten succeed because the loop is atomic. C. None succeed; bulk operations require an all-or-nothing guard. D. The loop crashes on the ninth iteration because guards do not handle re-entry.

Reveal answer

A — each call is independent, validates current state, returns boolean. purchase_building returns false on guard failure with no state changed for that call. The loop sees false for the ninth (and tenth) and either continues to the next iteration (caller's choice) or breaks. The first eight count increments and Light deductions are committed; the ninth's failure leaves Light wherever it was after the eighth (not enough for the ninth's cost). The "buy ×10" caller is responsible for handling the boolean returns — typically a loop that breaks on first failure: for i in range(10): if not GameState.purchase_building(data): break. B fabricates atomicity; the engine has no transactions. C describes a different design choice (all-or-nothing); the textbook chose per-purchase independence. D is wrong: guards are pure functions, no re-entry issues.

Q3 — You set _buildings['initiate'] = 50 directly from the Remote tab. The CounterLabel does not change; income does not visibly increase. Why?

A. Direct dict writes don't fire _recalculate_income(); the _income_rates cache is stale until the next purchase or a manual _recalculate_income() call. B. The Remote tab is read-only; the write didn't actually happen. C. Godot's reactivity layer auto-recomputes; the change is just delayed by one frame. D. _buildings is private (underscore prefix); writes are silently rejected.

Reveal answer

A — direct writes bypass the recompute path. The _recalculate_income() call is part of purchase_building's body. Direct mutations to _buildings skip the method entirely; the income cache reflects the last call to _recalculate_income(). The fix is to call _recalculate_income() manually from the REPL after the write. The pattern is identical to M3.2's discipline: production code paths run the recompute as part of the mutation; debug paths that bypass the production path also bypass the recompute. B is wrong: Remote-tab writes are real. C imagines reactivity that GDScript doesn't have. D is convention, not enforcement; underscore prefix doesn't reject writes.

Q4 — Parsons (ordering)

Below are eleven lines of GDScript, scrambled. Two are distractors (they look plausible but should not appear in the body of purchase_building). Identify the distractors and arrange the remaining lines in correct order with correct indentation:

A.     return false
B.     add_to_resource("light", -cost)
C.     building_purchased.emit(data)
D.     _buildings[data.id] = count + 1
E. func purchase_building(data: BuildingData) -> bool:
F.     var count: int = get_building_count(data.id)
G.     return true
H.     var cost: float = data.get_cost(count)
I.     if get_resource("light") < cost:
J.     _recalculate_income()
K.     _building_lookup[data.id] = data   # distractor candidate
L.     _purchased_upgrades.append(data.id) # distractor candidate

Reveal answer

Distractors: K (registration is M5.4's register_building job, not the purchase guard's — the lookup is populated once at boot, not on every purchase) and L (writes to the upgrades dictionary — wrong dictionary entirely; upgrades and buildings are sibling tables, not the same table).

Expected order (with indentation):

func purchase_building(data: BuildingData) -> bool:
    var count: int = get_building_count(data.id)
    var cost: float = data.get_cost(count)
    if get_resource("light") < cost:
        return false
    add_to_resource("light", -cost)
    _buildings[data.id] = count + 1
    _recalculate_income()
    building_purchased.emit(data)
    return true

The shape: read state into locals, validate against locals, mutate, recompute, emit, return. Reordering any pair breaks an invariant — for example, building_purchased.emit before _recalculate_income would let listeners observe a state where the count is updated but the income cache is still stale.

Integration question

Q5 — open

purchase_building reads count once, computes cost once, validates, deducts, increments. The same shape as M4.3's purchase_upgrade (validate, deduct, mutate, emit). What invariant do both methods preserve, and what would happen if a future purchase_building_or_upgrade(data) polymorphic dispatch tried to share more code between them?

Reveal expected answer

The invariant: no observable state change happens until all preconditions pass. Both methods read state, compute derived values, validate, then mutate. If validation fails, the method returns false with the world unchanged. This is the "validate-before-mutate" discipline, made enforceable by capturing relevant values into locals at the top, doing all reads through those locals, doing all writes after the guard. Sharing more code between purchase_upgrade and purchase_building is tempting because the shape rhymes. Possible factorings: (1) extract _check_affordability(cost) -> bool and call from both — fine, both methods pay this cost. (2) Extract a _purchase_with_validation(callable_validate, callable_mutate) higher-order function — fights the language, hides the validation from the read site. (3) Polymorphic dispatch (UpgradeData.purchase() and BuildingData.purchase() overrides) — moves the purchase logic onto the data class, which violates the architectural separation: data classes should not call back into GameState. The textbook's choice is two parallel methods with a shared invariant pattern but no shared code. The structural similarity is intentional pedagogy: students recognize "this is the same shape as M4.3," not "this delegates to a shared helper." For two methods, repetition is a feature; for ten, factor a helper. The threshold is taste, but five lines of duplicated discipline is rarely the bug.

Glossary

Glossary

_buildings dictionary
The dictionary holding building counts in GameState. Keyed by BuildingData.id (String), valued by current count (int). Single source of truth for the building economy: M5.3 increments via purchase_building; M5.4 reads to compute income; M7 saves/loads. Default is 0 for any unmentioned key (via .get(id, 0)).
get_building_count(id)
The accessor on GameState returning the current owned count of a building. Returns 0 for missing keys; never raises. Used by BuildingRow._refresh for label values and by _recalculate_income for output aggregation.
repeatable purchase
A transaction that can be performed N times, each at a scaled cost, with mutation expressed as count-increment. Distinct from one-time purchase (M4.3) where the mutation is a flag. Buildings, ascensions, and gachas are repeatable; upgrades, achievements, and unlocks are one-time.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Trace one Initiate purchase from button click to label update — show every method call, signal emit, and dictionary mutation in order.` - `Show me a "buy ×10" implementation that breaks on first failure. What's the code for the loop?` - `Compare repeatable-purchase shape with one-time-purchase shape side by side. Where do they overlap, where do they diverge?`