Skip to content

Purchase + Apply Effect

A milestone

M4.3 is where the textbook's upgrade system becomes interactive. By the end of this chapter you will buy Blessing of Might in a running game, watch your Light total drop by 50, and observe that every subsequent click earns 2.0 Light instead of 1.0. This is the first time in the textbook that the player's choice — which upgrade to buy and when — produces a permanent change to gameplay. Until now every interaction (clicking the Train button) was symmetric: each click identical to the last. From M4.3 onward, the player can make decisions that compound, and the game state begins to reflect player history. Every later module deepens this decision space: M5 adds repeatable building purchases, M6 adds the meta-decision of when to prestige, M7 makes those decisions survive a quit.

What you'll learn

  • The purchase guard pattern: a single method that validates affordability, validates one-time-only state, deducts cost, applies effect, and emits a signal — all of which fail-closed if any prerequisite is missing.
  • How to use a match statement against an enum-tagged string (upgrade.effect_type) to dispatch effect application without polymorphism.
  • The role of a Callable bound at the connection site (btn.pressed.connect(func(): GameState.purchase_upgrade(upgrade))) to carry per-row data into a shared handler.
  • Why _purchased_upgrades is a Dictionary[String, UpgradeData] keyed by id — O(1) ownership lookup, single source of truth for save serialization.
  • How to refactor the existing click handler so Light.value += GameState.click_value reads the current multiplier, letting upgrades amplify clicks without touching top_bar.gd.

How it applies

  • One-place affordability rules. Every purchase — Blessings, Sermons, future Buildings, M6 Crusade — flows through one method that knows the rules: not already owned, sufficient resource, deduct then apply. New rules (cooldowns, prerequisite upgrades, soft-cap discounts) land in one place. Bugs around "purchased twice" or "purchased without paying" are unreachable when the discipline is single-method.
  • Save format clarity. _purchased_upgrades is the save's "owned upgrades" section — a list of ids. M7's serializer dumps the keys; load reads the keys and looks up each UpgradeData from a global registry, then re-applies effects by replaying the purchase logic. The state-application logic and the load-restoration logic share the same code.
  • Modding hooks. A mod that wants to add "free first upgrade" logic wraps purchase_upgrade — checks if the player has any owned upgrades and overrides the cost branch. The hook point is one method; the mod does not need to know about the click handler, the tick handler, or the Label refresh.
  • QA scriptability. A QA test for "buying Blessing of Might doubles click value" calls GameState.purchase_upgrade(blessing_data) directly, then asserts GameState.click_value == 2.0. No UI simulation required; the production code path is the test path.
  • Live-tuning seam. A designer experimenting with multiplier values edits the .tres, hits Save, and the running game picks up the new value on the next purchase — owned upgrades retain their previously-applied multipliers, but the data file controls future applications. The Inspector becomes the balance dial.

Concepts

The purchase guard

The purchase guard is one method that does the entire transaction:

func purchase_upgrade(upgrade: UpgradeData) -> bool:
    if _purchased_upgrades.has(upgrade.id):
        return false
    if get_resource("light") < upgrade.cost:
        return false
    add_to_resource("light", -upgrade.cost)
    _purchased_upgrades[upgrade.id] = upgrade
    _apply_upgrade_effect(upgrade)
    upgrade_purchased.emit(upgrade)
    return true

The structure is "validate, validate, mutate, mutate, mutate, notify." Every check that could fail comes before any side effect; if any check fails the method returns false with no state changed. Once past the checks, the state changes happen in a fixed order: deduct cost first (so a downstream effect-application that increases the cost cannot create a free purchase), record ownership, apply the effect, emit the signal. The signal goes last so listeners observe a fully-committed state, not a half-applied one.

The boolean return is the contract: the caller (the Button's pressed handler in M4.2) can ignore it (GameState.purchase_upgrade(upgrade)) or use it (if GameState.purchase_upgrade(upgrade): play_purchase_sound()). The contract is "true if the transaction completed; false if any precondition blocked it."

Example

A second click on an already-owned Blessing's Button reaches purchase_upgrade again. The first check (_purchased_upgrades.has(upgrade.id)) returns true, the method returns false immediately, no Light is deducted, no effect re-applied. The discipline of "validate first" makes idempotent re-clicks safe; the row would also have disabled = true from the M4.2 refresh, but the method would still be safe even without the UI guard.

match against an enum-tagged string

UpgradeData.effect_type is a string with @export_enum constraint — the editor enforces "click" or "tick" at edit time, but the runtime value is still a String. To dispatch on it:

func _apply_upgrade_effect(upgrade: UpgradeData) -> void:
    match upgrade.effect_type:
        "click":
            click_value *= upgrade.multiplier
        "tick":
            tick_multiplier *= upgrade.multiplier

match works like switch with literal patterns. The first matching branch executes; no fall-through. A wildcard _ branch catches anything else; omitting it for known enums is fine, with anything else silently doing nothing.

The match-on-string pattern is preferred over polymorphism (subclass UpgradeData per effect type and override apply()) for two reasons. First, Resource subclasses do not Inspector-edit their type after creation — once a .tres is created with class UpgradeData, you cannot retag it BlessingData without recreating. Second, the dispatch logic lives in one method that reads top-to-bottom; a polymorphic alternative scatters it across N classes.

Example

A new effect type "auto_click" is added — auto-clicks once per second. The @export_enum annotation on UpgradeData.effect_type adds "auto_click" to the dropdown; the match statement adds a new branch. Two edits in one file, one new field if needed. Polymorphic dispatch would add a new UpgradeData subclass, edit the registry, and update any code that constructed UpgradeData directly. The string-tagged-match pattern's edit footprint is smaller for shallow polymorphism.

Callable as data binding

The Button row's pressed connection in M4.2 was empty. Now it carries the upgrade reference:

btn.pressed.connect(func(): GameState.purchase_upgrade(upgrade))

The lambda captures upgrade (the loop variable from _create_row) by reference. When pressed fires, the engine invokes the lambda with no arguments; the lambda body reads its captured upgrade and forwards to the shared purchase_upgrade method. This is the Callable-as-data-binding pattern: each row's connection captures its own data, all connections route to one shared handler.

The alternative Callable.bind produces a non-lambda equivalent:

btn.pressed.connect(GameState.purchase_upgrade.bind(upgrade))

bind(args) returns a Callable that pre-fills args into the call. When pressed fires (no args), the bound Callable invokes GameState.purchase_upgrade(upgrade). Equivalent to the lambda, slightly less typing, slightly less debuggable (a bound Callable shows up in stack traces as the bound method, not as an anonymous lambda).

Both forms work. The textbook uses the lambda form for clarity at the call site — the purchase_upgrade(upgrade) is right there to read.

Example

Twenty upgrade rows each connect their own pressed Callable to a fresh lambda. The signal-system overhead is twenty closures held in memory; each is a few dozen bytes. On queue_free-ing a row, its connection is automatically released because the row object is freed. No memory leak; no manual disconnect required. The cost of "one lambda per row" scales linearly and is invisible in any reasonable game.

_purchased_upgrades as a dictionary

[_purchased_upgrades: Dictionary[String, UpgradeData]]{.gloss title="The data structure storing the player's purchased upgrades. Keyed by upgrade.id (String), valued by UpgradeData reference. O(1) ownership lookup via .has(). Single source of truth for save serialization — the save writes the keys, restore reads keys and re-applies effects by walking each UpgradeData."} maps upgrade.id to the UpgradeData reference. Two reads matter:

  • Ownership check. _purchased_upgrades.has(upgrade.id) is O(1). The M4.2 refresh calls this for every entry in all_upgrades — for a hundred upgrades and ten refreshes per second the cost is microseconds.
  • Save serialization. The save dumps the keys. A list of fifty strings. Restore parses keys and looks up each UpgradeData in the project's registry (a known Array), re-applying effects by walking the list and calling _apply_upgrade_effect per entry. The save format is small and the round-trip is loss-free.

An Array[UpgradeData] would also work — Array.has() is O(N), but for small lists fine. The Dictionary scales better and gives O(1) lookup at zero extra storage cost (the same UpgradeData reference held by the entry, plus a hashed key).

Example

A late-game player has fifty owned upgrades. The M4.2 refresh checks ownership for each of fifty entries — fifty has() calls per refresh. Dictionary: 50 × O(1) = nanoseconds total. Array: 50 × O(50) = 2500 checks, microseconds total. Both are fine; the Dictionary's structure (ids as primary keys) also makes save serialization trivial. The data shape carries the access pattern; the access pattern justifies the data shape.

Walkthrough

You will perform these in your own Godot editor. Coming in, M4.2's UpgradeList displays two Buttons (Blessing, Sermon) but clicking them does nothing.

  1. Open scripts/game_state.gd. Add three new fields and a signal at the top of the file, near the existing signal resource_changed declaration:
    signal upgrade_purchased(upgrade: UpgradeData)
    
    var click_value: float = 1.0
    var tick_multiplier: float = 1.0
    var _purchased_upgrades: Dictionary[String, UpgradeData] = {}
    
    click_value is the per-click amount the click handler reads. tick_multiplier is the multiplier the tick handler applies to passive income. Both default to 1.0 (no upgrade-applied amplification yet). _purchased_upgrades is the dictionary of owned upgrades.
  2. Add the ownership check:
    func is_upgrade_purchased(id: String) -> bool:
        return _purchased_upgrades.has(id)
    
    One-line accessor. M4.2's refresh will call this to skip owned entries (added in step 8 below). Save (Ctrl+S).
  3. Add the purchase guard. The full code-fragment ceiling for this chapter, in one fragment:
    func purchase_upgrade(upgrade: UpgradeData) -> bool:
        if _purchased_upgrades.has(upgrade.id):
            return false
        if get_resource("light") < upgrade.cost:
            return false
        add_to_resource("light", -upgrade.cost)
        _purchased_upgrades[upgrade.id] = upgrade
        _apply_upgrade_effect(upgrade)
        upgrade_purchased.emit(upgrade)
        return true
    
    The order is mandatory: validate own-flag, validate affordability, deduct cost, record ownership, apply effect, emit. Inverting any pair creates a window where the state is inconsistent.
  4. Add the effect dispatcher:
    func _apply_upgrade_effect(upgrade: UpgradeData) -> void:
        match upgrade.effect_type:
            "click":
                click_value *= upgrade.multiplier
            "tick":
                tick_multiplier *= upgrade.multiplier
            _:
                push_warning("Unknown effect_type: %s" % upgrade.effect_type)
    
    Two value branches plus a _: wildcard. The wildcard fires push_warning for any effect_type value the match did not anticipate — .tres files edited by hand outside the @export_enum-constrained dropdown, schema additions made in one place but not propagated to the dispatcher, future effect types that ship before the dispatch is updated. Without the wildcard, Q3 below names the failure mode: silent fall-through means the cost is deducted, ownership is recorded, the row disappears, and nothing actually changes — a bug invisible to QA without per-upgrade verification. Save the script.
  5. Refactor the click handler. Open scripts/top_bar.gd. The existing _on_train_pressed does Light.value += 1.0. Change to:
    func _on_train_pressed() -> void:
        Light.value += GameState.click_value
    
    Now the per-click amount reads the current multiplier. After purchasing Blessing of Might (multiplier 2.0), click_value = 2.0 and each click adds 2.0. Save.
  6. Refactor the tick handler in scripts/light.gd. The existing _on_tick does value += light_per_second * tick_delta. Change to:
    func _on_tick(tick_delta: float) -> void:
        value += light_per_second * GameState.tick_multiplier * tick_delta
    
    Tick income now scales with tick_multiplier. After purchasing Sermon of Dawn, the multiplier becomes 2.0 and passive income doubles. Save.
  7. Wire the UpgradeList Button's pressed. Open scripts/upgrade_list.gd. In _create_row, after setting btn.text, add a pressed connection:
    btn.pressed.connect(func(): GameState.purchase_upgrade(upgrade))
    
    The lambda captures the upgrade variable by value, at the moment each lambda is created — so each row's lambda binds to its own upgrade. (GDScript lambdas snapshot the locals they use at creation time, not by reference; if they captured by reference, every row would share the final upgrade — the classic JavaScript closure-in-loop bug, which GDScript's by-value capture avoids.)
  8. Modify _create_row (or refresh) to skip owned upgrades and disable unaffordable ones. The simpler form modifies refresh:
    func refresh() -> void:
        for child in get_children():
            child.queue_free()
        for upgrade in all_upgrades:
            if GameState.is_upgrade_purchased(upgrade.id):
                continue
            add_child(_create_row(upgrade))
    
    And in _create_row, after the pressed connection:
    btn.disabled = GameState.get_resource("light") < upgrade.cost
    
    Owned upgrades are skipped entirely; unaffordable upgrades render but the Button is disabled (greyed out, click rejected by the Button itself).
  9. Connect refresh triggers in _ready:
    func _ready() -> void:
        GameState.upgrade_purchased.connect(_on_changed)
        GameState.resource_changed.connect(_on_resource_changed)
        refresh()
    
    func _on_changed(_upgrade: UpgradeData) -> void:
        refresh()
    
    func _on_resource_changed(_name: String, _value: float) -> void:
        refresh()
    
    Two refresh triggers: when an upgrade is purchased (the row should disappear), and when any resource value changes (affordability of remaining rows may change). The _resource_changed connection over-fires (every Light tick refreshes the whole list), which is fine for a few-row list but the M5 chapter's pitfalls list flags it for throttling.
  10. Save all scripts. Press F5. The list shows Blessing and Sermon, both disabled (Light = 0, cost = 50 / 200). Click the Train button to grow Light. After 50 clicks, Blessing's row enables. Click it. The Light total drops by 50; the Blessing row disappears (is_upgrade_purchased filters it out). Click Train again — each click now adds 2.0 Light because click_value is 2.0. Continue clicking until Light reaches 200; the Sermon row enables. Click Sermon. Light drops by 200; Sermon disappears; passive income (which has been zero so far) is multiplied by 2.0 (still zero — no generators yet, M5 will add them). Close the game.

Optional sanity check. Before pressing F5, in Light._ready add light_per_second = 1.0 (so passive income is nonzero). Run, click Train to grow Light, buy Sermon. The counter Label tick rate doubles after the purchase — was incrementing by 0.1 per tick (1.0 × 1.0 × 0.1), now 0.2 per tick (1.0 × 2.0 × 0.1). Reset light_per_second = 0.0 after testing.

Self-check quiz

Q1 — In purchase_upgrade, you swap the order so add_to_resource('light', -upgrade.cost) runs before the affordability check. The player has 30 Light, an upgrade costs 50. What happens?

A. The check still rejects the purchase; the engine reverts the cost deduction. B. Light becomes -20, then clamps to 0 by the setter; the check then rejects but state is already changed. C. Light becomes -20 (no setter clamp on the deduct path); the check rejects and returns false but Light total is now wrong. D. The engine raises "negative cost not allowed."

Reveal answer

B — partial state change before the check, with the setter's clamp masking part of the bug. add_to_resource runs the setter, which (per M2.2) clamps to >= 0. So 30 - 50 = -20 clamps to 0. The affordability check get_resource("light") < upgrade.cost reads 0 < 50 and returns true (still unaffordable), so the method returns false. But the Light total is now 0, not 30 — the player just lost 30 Light to a purchase that was rejected. This is exactly why "validate first, mutate after" is the discipline. A imagines transactional state — Godot has no auto-rollback. C describes the unclamped case which the setter prevents in this specific code path; the bug is still real, just less catastrophic. D fabricates an error path. The lesson: the order of operations in the guard is load-bearing.

Q2 — You replace the lambda in the row connection with btn.pressed.connect(GameState.purchase_upgrade). The Button's pressed signal carries no arguments. What happens when the player clicks?

A. The connection works — Godot fills upgrade from the row's context automatically. B. The connection raises "Signal/Callable signature mismatch" at connect time — pressed has zero parameters but purchase_upgrade requires an UpgradeData parameter. C. purchase_upgrade is called with null for upgrade; the first if .has(...) line crashes. D. The connection silently no-ops; clicks do nothing.

Reveal answer

B — argument-count mismatch caught at connect time. Godot validates the signal's declared parameter count against the Callable's parameter count when connect() is called. pressed() has zero parameters; purchase_upgrade(upgrade: UpgradeData) requires one. The mismatch raises an error during _ready() execution. The fix is the lambda form (func(): GameState.purchase_upgrade(upgrade)) or the bound form (GameState.purchase_upgrade.bind(upgrade)) — both produce a zero-argument Callable that supplies the upgrade argument from captured/bound state. A imagines context-aware signal routing that does not exist. C imagines a null default for unsupplied arguments — the connect call rejects before any null could be passed. D is too lenient; Godot raises, doesn't silently skip.

Q3 — _apply_upgrade_effect uses match upgrade.effect_type with branches 'click' and 'tick' only. A future UpgradeData is authored with effect_type = 'auto_click' (a value the @export_enum does not currently include — the field was edited via raw .tres text). What happens when this upgrade is purchased?

A. The match raises "no matching pattern." B. The match falls through silently — the effect is never applied, but the upgrade is recorded as purchased with cost deducted. C. The first branch executes as a default fallback. D. The runtime auto-skips the purchase and refunds the cost.

Reveal answer

B — silent fall-through on missing branches. GDScript's match does not require exhaustiveness. A pattern that matches no branch silently produces no effect; control returns from the method as if the body were empty. The cost is deducted (line above the match), the upgrade is recorded (line above the match), the signal is emitted (line below), but the effect — the point of the upgrade — never applied. The bug is invisible: the player sees the purchase confirmed, the row disappears, and something feels off in the gameplay. The defensive fix is a _ wildcard branch that pushes a warning: _: push_warning("Unknown effect_type: ", upgrade.effect_type). A overstates: GDScript match is permissive. C fabricates fallback semantics. D imagines auto-refund logic that does not exist.

Integration question

Q4 — open

Trace the flow of one Blessing of Might purchase, from the row Button's pressed emit to the next click being worth 2.0 Light. List every method, signal, and state change in order. How many of M2's, M3's, and M4's systems participate?

Reveal expected answer

(1) Button.pressed emits. (2) The lambda connected to the Button — func(): GameState.purchase_upgrade(upgrade) — invokes GameState.purchase_upgrade(blessing_data). (3) The guard validates _purchased_upgrades.has("blessing_of_might") is false, then get_resource("light") >= 50.0 is true. (4) add_to_resource("light", -50.0) runs — the setter on _resources["light"] deducts and emits resource_changed("light", new_total). The CounterLabel's listener (M2.3) updates the displayed total, dropping by 50. (5) _purchased_upgrades["blessing_of_might"] = blessing_data records ownership. (6) _apply_upgrade_effect(blessing_data) runs — match hits the "click" branchclick_value *= 2.0, becoming 2.0. (7) upgrade_purchased.emit(blessing_data) fires. (8) UpgradeList._on_changed runs — refresh() clears children with queue_free, iterates all_upgrades (M4.2's loop), skips Blessing (M4.3's is_upgrade_purchased filter), creates one row for Sermon. The Blessing row is gone. (9) On the next click, _on_train_pressed runs Light.value += GameState.click_valueLight.value += 2.0. The setter clamps and emits value_changed(2.0); the CounterLabel updates. Systems: M2.2 setter+signal pattern (twice — once for _resources["light"] deduction, once for Light.value increment), M2.3 autoload + parameterized signal listener (CounterLabel's update path), M3.3 multi-resource ledger (get_resource/add_to_resource), M4.1 UpgradeData (data fields read by guard and effect), M4.2 dynamic UI (refresh on upgrade_purchased), M4.3 purchase guard (the orchestrator). Six systems participate; the integration point is purchase_upgrade, which calls into M3.3, reads M4.1, fires M4.3's signal, triggers M4.2's refresh, and returns the boolean to the row's lambda. The deeper architectural property: every system has one job, and purchase_upgrade is the choreographer that sequences them.

Glossary

Glossary

purchase guard
A pattern where a single method validates every precondition for a transaction (affordability, one-time-only state, prerequisites), deducts cost, applies effect, and emits a notification — all in sequence. Returns true on success, false on any failed precondition. The single choke point for the transaction's invariants.
match statement
GDScript's pattern-matching statement. Works like switch on Variant values, with literal patterns, wildcard _, and array/dictionary destructuring. The first matching branch executes; no fall-through. Permissive — non-matching values silently produce no effect unless a wildcard catches them.
Callable.bind(args)
A method on Callable that returns a new Callable with args pre-filled into the call. f.bind(x).call() is equivalent to f.call(x). Useful for connecting per-row data into a shared signal handler when a lambda would also work.
owned-upgrades dictionary
The data structure storing the player's purchased upgrades, keyed by upgrade.id (String), valued by UpgradeData reference. O(1) ownership lookup via .has(). Single source of truth for save serialization — save writes keys, restore reads keys and re-applies effects.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Trace what happens if I click Blessing's row Button while Light is exactly 50.0 — print the resource value and click_value at every step inside purchase_upgrade.` - `Show me the equivalent of match upgrade.effect_type written as polymorphic dispatch — UpgradeData subclasses with overridden apply() — and tell me why M4.3 picks the match form.` - `Re-explain Callable.bind using a Python functools.partial analogy.`