Skip to content

Unlock Thresholds

What you'll learn

  • The unlock threshold pattern: an upgrade does not appear in the list until the player's relevant resource crosses a per-upgrade threshold, set on the UpgradeData itself.
  • Why the threshold is separate from the cost — typically ~50% of cost — and what design pressure that ratio addresses.
  • The three states an upgrade can be in: locked (below threshold, hidden), available (visible, affordable or not), owned (purchased, removed). One refresh pass sorts every entry into one of these.
  • Node.set_meta(key, value) and Node.get_meta(key) for attaching arbitrary per-instance data to a Node — when the data does not fit a typed property and you do not want a subclass.
  • Why threshold changes refresh on resource_changed (the same signal that already drives the affordability re-check), with no extra plumbing required.

How it applies

  • Curiosity-driven progression. A locked upgrade is invisible; an available-but-unaffordable upgrade is a visible goal with a number to grind toward. Setting unlock_threshold ≈ 0.5 × cost means players see Blessing of Might at 25 Light when the cost is 50 — half the work is already done, the goal is concrete, the choice to grind is informed.
  • Tutorialization without tutorial. The first hour of an idle game is a tutorial whether the developer plans for it or not. Threshold-gated unlocks pace the introduction of mechanics: clicks alone for the first thirty seconds, then Blessing appears, then Sermon, then the M5 building list begins to populate. The data-driven pacing replaces a hand-coded onboarding flow.
  • Spoiler control. Late-game upgrades can have thresholds set far above their costs (e.g., unlock_threshold = cost, so the player must reach the cost level by some other means before the upgrade becomes visible). This hides progression branches the designer wants the player to discover. M6's prestige flow uses the same gate: the Crusade button is invisible until a threshold of lifetime Light is met.
  • QA test plans. "Verify Blessing of Might appears at 25 Light, regardless of how the player got there." The test sets Light.value = 25.0 directly and asserts the row is present. Same test works for Light.value = 50.0 (still present), Light.value = 0.0 (absent). The threshold is one numeric field; the test is one assertion.
  • Save-resilience. Loading a save with Light.value = 1000.0 triggers the same threshold check at refresh time as a live player crossing the threshold. The upgrade list reflects current state regardless of how the state arrived. No special "load-time refresh" code; the existing refresh wins.

Concepts

The locked / available / owned tri-state

Every upgrade in all_upgrades is in one of three states at refresh time:

  • lockedGameState.get_resource(unlock_resource) < unlock_threshold. Hidden from the list. The player does not know the upgrade exists yet.
  • available — threshold met, not owned. Rendered as a row; the Button is disabled if the player cannot currently afford it (M4.3) and enabled if they can.
  • owned_purchased_upgrades.has(upgrade.id). Skipped by the refresh; M4.3 already handles this.

The refresh function checks these three conditions in order: skip if owned, skip if locked, otherwise render. The order matters slightly — checking owned first short-circuits any threshold check on already-purchased upgrades, which is cheap but also conceptually right (an owned upgrade is done; nothing about its threshold matters anymore).

Example

A player at 30 Light with no purchases sees one row: Blessing of Might (threshold 25, cost 50, available-but-unaffordable). Sermon of Dawn (threshold 100, cost 200) is locked. After clicking up to 60 Light, Blessing's row enables; the player buys it. The list now shows nothing — Sermon is still locked. After grinding to 100 Light, Sermon appears (locked → available). The progression is paced by thresholds; the player always sees the next milestone.

Threshold as a separate field from cost

A naive design would set threshold = cost — the upgrade appears the moment it becomes affordable. This skips the "I see what's coming, I need to grind for it" experience that idle games depend on. A player who sees Blessing the same instant they can afford it has no anticipation; the tension that motivates the click-grind never materializes.

The convention in idle/incremental design is threshold ≈ 0.5 × cost, sometimes lower (0.3 × cost) for headline upgrades. The player sees the upgrade with half the cost already in pocket, knows what they are working toward, and continues the grind with a concrete goal. The exact ratio is a tuning knob — 0.5 for early upgrades (motivating), 0.3 for mid-game upgrades (less time staring), 1.0 for hidden late-game upgrades (no preview).

unlock_threshold is a @export var on UpgradeData, separate from cost, which lets a designer tune the two independently. Lowering the threshold without touching cost makes upgrades preview earlier; raising the threshold without touching cost gates content harder.

Example

A balance pass discovers that Blessing's threshold of 25 makes new players quit before they see it — they grind for thirty seconds and give up. Lowering the threshold to 5 makes Blessing visible after five clicks; the player sees the goal, the dropoff rate falls. Cost stays at 50, so the gameplay between sighting and purchase is unchanged. The two-knob design lets the data drive funnel decisions without rewriting code.

set_meta and get_meta — Node-attached arbitrary data

The Button row's connection to its upgrade is the lambda func(): GameState.purchase_upgrade(upgrade) that captures the upgrade by value at creation (each row's lambda keeps its own upgrade snapshot). There is no other path from the Button back to "which upgrade am I for." If the row needed that path — e.g., a tooltip system that asks "Button, which UpgradeData are you bound to?" — the row would need to carry the upgrade reference somehow.

Node.set_meta(key, value) stores arbitrary key-value data on a node. set_meta("upgrade", upgrade) attaches the UpgradeData reference to the Button under the key "upgrade". Other code can read it via get_meta("upgrade"). The metadata Dictionary survives until the node is freed; not type-checked, not Inspector-visible by default.

set_meta is the right tool when:

  • A Node needs to carry per-instance data the parent class doesn't have a property for.
  • You don't want to subclass (extends Button for one extra field is too much).
  • The data is engine-internal — used by your own code, not the engine's.

For M4.4's row, attaching the upgrade as metadata is one of several options. The textbook does not require it (the lambda capture covers the access path used in M4.3); it is mentioned as a tool for future tooltip / hover systems where the row needs to be queryable from outside its own connections.

Example

A debug "list rows" command iterates UpgradeList.get_children() and asks each Button which upgrade it represents. With set_meta("upgrade", upgrade) set in _create_row, the loop reads child.get_meta("upgrade").id for each. Without metadata, the only way to recover the binding is to parse the Button's text field — fragile and locale-dependent. set_meta decouples display from identity.

Threshold changes refresh on resource_changed

The threshold check uses GameState.get_resource(upgrade.unlock_resource). This value changes on every Light tick, every click, every save-load. The UpgradeList already subscribes to GameState.resource_changed (M4.3's affordability re-check); the same connection covers threshold transitions.

When Light crosses 25, the next resource_changed("light", 26.0) fires; UpgradeList._on_resource_changed calls refresh(); the refresh re-evaluates every entry's threshold; Blessing transitions from locked-skip to available-render. The row appears.

The over-firing concern from M4.3 — refresh runs on every tick, even when nothing visibly changes — is unchanged. For under twenty rows the cost is invisible. For larger lists the M5 chapter introduces a dirty-flag throttle.

Example

Light is currently 24, Blessing's threshold is 25. The next tick fires resource_changed("light", 24.5); refresh runs; Blessing is still locked (24.5 < 25); the loop emits no row. Two ticks later, resource_changed("light", 25.5); refresh runs; Blessing's threshold check passes; the row is rendered. The transition is reactive — no polling, no manual unlock notification, just re-evaluating the data on every change.

Walkthrough

You will perform these in your own Godot editor. Coming in, M4.3's UpgradeList displays rows for upgrades the player can see and afford; owned upgrades are skipped.

  1. Open scripts/upgrade_list.gd. The current refresh skips owned upgrades. Add a second skip clause for the threshold check:
    func refresh() -> void:
        for child in get_children():
            child.queue_free()
        for upgrade in all_upgrades:
            if GameState.is_upgrade_purchased(upgrade.id):
                continue
            if GameState.get_resource(upgrade.unlock_resource) < upgrade.unlock_threshold:
                continue
            add_child(_create_row(upgrade))
    
    The order is owned-first, threshold-second. unlock_resource is the field added in M4.1 (defaulting to "light"); unlock_threshold is the float field. Save (Ctrl+S).
  2. Verify the existing .tres files have meaningful thresholds. Click blessing_of_might.tres. In the Inspector, confirm Unlock Threshold = 25.0. Click sermon_of_dawn.tres; confirm Unlock Threshold = 100.0. (M4.1's walkthrough set these values.)
  3. Press F5. The list is initially empty — Light is 0, both upgrades are locked. Click TrainButton. Each click adds 1 Light. After 25 clicks (or 25 ticks if light_per_second > 0), Blessing of Might appears in the list, disabled (cost 50, current 25). Continue clicking; at 50, Blessing becomes enabled. Buy it. The list is empty again — Blessing is owned, Sermon is still locked (need 100, current 0). Continue.
  4. With Blessing owned (click_value = 2.0), each click adds 2 Light. From 0, fifty clicks reaches 100 Light; Sermon appears (locked → available, cost 200). Continue clicking until 200 Light; Sermon enables; buy it. The list is empty again. Stop the game.
  5. Add a third upgrade as a test of the threshold pattern under load. Right-click resources/upgrades/ → New Resource → UpgradeData. Save as relic_of_dawn.tres. Inspector:
  6. Id: relic_of_dawn
  7. Display Name: Relic of Dawn
  8. Description: Triples passive Light income.
  9. Cost: 1000.0
  10. Effect Type: tick
  11. Multiplier: 3.0
  12. Unlock Resource: light
  13. Unlock Threshold: 300.0
  14. Drag relic_of_dawn.tres into UpgradeList's all_upgrades array (Inspector when UpgradeList is selected; click +, drop the file). Save the scene.
  15. Press F5. The locked / available / owned states now produce a small staircase: blank list at 0, Blessing visible at 25, Sermon visible at 100, Relic visible at 300. Buying Blessing (50) and Sermon (200) leaves only Relic. The display tracks every threshold crossing and every purchase by re-running the refresh loop.
  16. Add set_meta to the row factory as the optional alternative path. In _create_row, after btn.text = ..., add:
    btn.set_meta("upgrade", upgrade)
    
    No effect on existing behavior — the lambda still captures the upgrade for the pressed connection. The metadata is available for any future code that needs to ask the Button "which upgrade are you for?" without parsing display text.
  17. Verify set_meta from the Remote tab. Run the game. Open Remote tab, navigate to one of the rendered Buttons under UpgradeList. The Button's metadata appears in the Inspector under "Metadata" — a Dictionary with one entry, upgrade → <UpgradeData reference>. Click the reference; the Inspector shows the bound UpgradeData's fields. The binding is queryable from outside the script; tooltip systems and debug tools can use it without parsing.
  18. Save all scripts. Stop the game. The walkthrough's deliverable is a list that paces the player through Blessing → Sermon → Relic via thresholds, with each upgrade's data driving its appearance and purchase semantics.

Optional sanity check. Edit relic_of_dawn.tres — set Unlock Threshold to 0.0. Save. Re-launch. Relic appears at game start (always passes the threshold check), disabled (cost 1000). The threshold field is a single dial controlling preview timing; setting it to zero means "always show"; setting it equal to cost means "show only when affordable" (no anticipation gap). Restore the threshold to 300.0 after testing.

Self-check quiz

Q1 — Three upgrades have thresholds 25, 100, 300 and costs 50, 200, 1000. The player has Light = 150 and has owned exactly the second one (Sermon, threshold 100). What does the refresh render?

A. Three rows: Blessing, Sermon, Relic. B. Two rows: Blessing (available) and Sermon (owned, marked). C. One row: Blessing, available. D. Zero rows: all upgrades fail one of the gates.

Reveal answer

C — one row, Blessing. Walk the gates per upgrade. Blessing: not owned (skip-owned passes), threshold 25 ≤ 150 (skip-locked passes), rendered. Sermon: owned (skip-owned triggers, no row). Relic: not owned, threshold 300 > 150, locked (skip-locked triggers, no row). Result: one row, Blessing. A misses the owned-and-locked filters. B misunderstands the refresh — owned upgrades are not rendered with a "marked" state, they are removed from the list entirely. D is wrong: Blessing passes both gates. The lesson: the refresh's order is owned-first, locked-second; the gates are independent skip-conditions, not display states.

Q2 — You set unlock_threshold = cost on every upgrade. The intent: 'show the upgrade exactly when it becomes affordable.' What player-experience effect does this produce, and why is the textbook's ~0.5 × cost convention preferred?

A. The threshold is meaningless — it does the same job as the affordability check on the Button. B. The locked → available transition is invisible (it coincides with the disabled → enabled transition); the upgrade pops in already-purchasable, denying the player the anticipation that motivates the grind. C. The Inspector flags this as an error. D. The save format breaks because threshold and cost must be different fields.

Reveal answer

B — coincident gates collapse the anticipation phase. The locked-to-available transition's job is to introduce the upgrade as a goal; the disabled-to-enabled transition's job is to make it actionable. Setting threshold = cost means both transitions happen on the same tick — the player sees the upgrade for the first time already enabled, with no time to want it. The grind that produced the resources happened in the dark; the upgrade appearing was the same event as the upgrade being affordable. Idle/incremental design uses the gap between thresholds and costs as the engagement loop's pacing element. A is half-right (the threshold check is structurally the same shape as the affordability check) but misses why the gap matters. C fabricates a validator. D is wrong: the fields are independent and the format does not enforce a relationship.

Q3 — A future feature: a tooltip system that asks 'show me which upgrade is bound to the Button under the cursor.' Which approach scales best across a list of fifty rows?

A. Parse the Button's text field — search all_upgrades for one whose display_name matches. B. Maintain a parallel Dictionary[Button, UpgradeData] updated by _create_row. C. Use Node.set_meta("upgrade", upgrade) in _create_row; the tooltip reads button.get_meta("upgrade"). D. Subclass Button to add an upgrade: UpgradeData typed field.

Reveal answer

C — set_meta is the right scale of solution. It binds per-instance data to a Node without subclassing or external bookkeeping. The metadata travels with the Button; freeing the Button frees the binding; no parallel Dictionary to update. A is fragile: locale changes, theme changes, or display_name collisions would break the binding. B works but duplicates state — every _create_row adds an entry, every queue_free would need a paired removal, and forgetting either creates a stale entry. D works but is heavy: a one-field subclass for a single use case. set_meta is the design weight that matches the design need.

Integration question

Q4 — open

The complete M4 system is now four sub-units of work: (a) UpgradeData Resource (M4.1), (b) dynamic UpgradeList UI (M4.2), (c) purchase_upgrade guard with effect dispatch (M4.3), (d) unlock thresholds gating visibility (M4.4). Suppose M5 introduces BuildingData — a sibling Resource type for repeatable purchases (buy as many Initiates as you can afford, each adding to passive income). How much of the M4 architecture does BuildingData reuse, and what are the genuinely new pieces M5 must introduce?

Reveal expected answer

Reused: the Resource + class_name + @export pattern from M4.1 (BuildingData declares its own fields with the same shape); the dynamic-UI list pattern from M4.2 (a BuildingList extending VBoxContainer with @export var all_buildings: Array[BuildingData]); the purchase-guard discipline from M4.3 (GameState.purchase_building(data) validates affordability, deducts cost, applies effect, emits a signal); the threshold-and-refresh pattern from M4.4 (unlock_threshold field on BuildingData, list refresh on resource_changed). Genuinely new: (1) repeatable purchases — a Building can be owned multiple times, so _buildings is Dictionary[String, int] (id → count) instead of Dictionary[String, UpgradeData] (the count concept is new). (2) Cost scaling — each purchase costs base_cost × cost_multiplier^count (M5.2), so get_cost(count) is a method on BuildingData rather than a bare cost field. (3) The purchase row UI shows current count + scaled cost and updates per purchase rather than disappearing (M5.3 walks this). (4) Building output integrates with the M3.1 tick path through _recalculate_income() on GameState (M5.4 plumbs this). The deeper architectural property: the textbook's first four modules built a vocabulary — Resource for data, dynamic UI for lists, purchase guards for transactions, threshold gates for pacing. M5 reuses every piece; what's new is the count-and-scaling shape that buildings have but upgrades don't. Each module contributes one canonical pattern; later modules compose them.

Glossary

Glossary

locked / available / owned
The three states an upgrade entry can be in at refresh time. Locked: unlock_threshold not yet crossed; hidden. Available: visible row, with Button enabled or disabled by affordability. Owned: purchased, removed from the list.
unlock threshold
A field on UpgradeData that gates the upgrade's visibility. While GameState.get_resource(unlock_resource) is below this value, the upgrade is hidden. Conventionally ≈ 0.5 × cost to give players an anticipation gap between sighting and affordability.
Node.set_meta(key, value) / Node.get_meta(key)
A method on every Node that attaches arbitrary key→value data to the node's metadata Dictionary. Survives until the node is freed. Distinct from properties: not type-checked, not Inspector-visible by default. Useful for binding per-instance data to a Node without subclassing.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Show me three .tres files (Blessing, Sermon, Relic) with the threshold-to-cost ratio at 0.5, 0.3, 1.0 respectively. What does each ratio do to the player experience?` - `Walk me through the refresh function execution at Light = 25.0 with two upgrades — Blessing (threshold 25) and Sermon (threshold 100). Show the loop iteration order, the gate evaluations, and the final children of UpgradeList.` - `Compare set_meta with React's data-* HTML attributes — same use case, different mechanism, what's the lesson?`