Skip to content

Output Integration with Tick

What you'll learn

  • How the tick handler in GameState reads the _income_rates cache (M3.3) and writes per-resource amounts to _resources, replacing the per-resource handlers from earlier modules.
  • The register_building(data) method that replaces M5.3's leaky _building_lookup direct write — a small refactor that preserves architectural separation between data classes and the autoload.
  • The full end-to-end purchase-to-income loop: click Buy → purchase_building_recalculate_income → next tick fires → _on_tick reads _income_ratesadd_to_resourceresource_changed emits → CounterLabel and BuildingRow refresh.
  • Why income aggregation lives in _recalculate_income (called on mutation) and not in _on_tick (called every 100 ms) — the recompute-on-change discipline from M3.2 made the right design at the small scale, and M5.4 confirms it scales to a multi-resource, multi-tier economy.
  • How M5.4 closes Module 5: the building economy is now a complete loop with no manual stepping, no per-resource handlers, and one ledger of state (_buildings) feeding one ledger of cached rates (_income_rates).

How it applies

  • Frame-budget discipline. _on_tick runs ten times per second. If it walked _buildings × _all_buildings_data × upgrade-multipliers every tick, a player with 5 tiers and 200 of each (1,000 buildings) would do 1,000 multiplies per tick × 10 ticks = 10,000 multiplies/second of purely redundant math. Reading a five-entry cached Dictionary is 5 reads/tick. The cache pattern is not premature optimization; it is the linear-versus-quadratic difference that idle games hit early.
  • Determinism for save/load. Income is a function of _buildings (counts) and the multipliers in scope. Save _buildings; load it; _recalculate_income() reconstructs _income_rates exactly. There is no need to save _income_rates itself — it is derived state. M7 will save the source-of-truth ledgers (counts, owned upgrades) and recompute everything else on load.
  • Multiplier stacking. Click upgrades (M4) multiply click_value. Sermon upgrades multiply tick_multiplier. Building output (M5) multiplied by tick_multiplier. Adding a new multiplier — e.g., a Crusade Honor multiplier (M6) — is one new term in _recalculate_income's body and one source of stacking. The multipliers compose in a single recompute, not at tick time.
  • Per-resource economies. The current build flows Light from buildings. Adding a Honor-from-Champions stream (or a Faith-from-Sermons stream) is a new resource entry plus a new branch in the recompute. The tick handler does not care which resource an entry targets; it walks every key in _income_rates and adds.
  • Test surface. Income tests assert on _recalculate_income against a fixed _buildings shape — pure function, no ticks, no time. Tick tests assert on _on_tick(0.1) adding the cached rate × delta — one branch, one read. Two narrow test surfaces beat one fat "buy a building, wait, assert income" integration test that depends on real-time elapsed.

Concepts

Centralized tick handler in GameState

Until M5.4, Light had its own _on_tick handler that walked its own generator list. With M3.3 introducing GameState's _resources Dictionary and M5.3 introducing per-resource _income_rates, the Light._on_tick is now redundant — GameState knows every resource's rate, every resource's value, and every signal.

func _on_tick(tick_delta: float) -> void:
    for resource_name in _income_rates:
        var amount: float = _income_rates[resource_name] * tick_delta
        if amount > 0.0:
            add_to_resource(resource_name, amount)

Four lines. Walks every entry in _income_rates, multiplies the per-second rate by the tick delta (0.1 s by default), and routes through add_to_resource which fires resource_changed. The Light autoload's _on_tick is deleted; GameState is now the single tick consumer.

Example

With _income_rates = {"light": 6.2, "honor": 0.0} and tick_delta = 0.1: the loop sees "light" (amount = 0.62, > 0, added), then "honor" (amount = 0.0, skipped). Two iterations, one add_to_resource call, one resource_changed emit. The tick_delta = 0.1 invariant lives in Tick.TICK_INTERVAL; the handler does not hardcode it.

centralized tick handler

register_building(data) — the M5.3 refactor

M5.3 wrote directly to GameState._building_lookup from BuildingList._ready. The underscore prefix is a "private" hint that the leaky direct-write violates. M5.4 introduces a public method:

func register_building(data: BuildingData) -> void:
    _building_lookup[data.id] = data

Two lines. The caller writes through the public API; the autoload owns the dictionary. If register_building later needs to validate (no duplicate ids, no null data, etc.) the validation lives in one place. If _building_lookup is renamed or restructured, callers don't change.

BuildingList._ready becomes:

for data in all_buildings:
    GameState.register_building(data)

Example

A future feature adds runtime building unlocks (e.g., a quest grants the Champion tier). The unlock code calls GameState.register_building(champion_data); the building appears in _building_lookup and is immediately walkable by _recalculate_income. The caller does not know or care that _building_lookup is a Dictionary — register_building is the contract. Swapping to an Array internally would not break the caller.

register_building(data)

The end-to-end purchase-to-income loop

With M5.3 + M5.4 wired, one button click drives a chain that touches every system from M2 through M5:

  1. The Buy button on building_row.tscn emits pressed.
  2. BuildingRow._on_buy_pressed calls GameState.purchase_building(building).
  3. purchase_building reads count, computes scaled cost, validates against get_resource("light"), deducts, increments _buildings[id], calls _recalculate_income(), emits building_purchased, returns true.
  4. _recalculate_income walks _buildings × _building_lookup, multiplies counts by base_output, sums into totals["light"], applies tick_multiplier, writes to _income_rates.
  5. building_purchased.emit(data) fires; BuildingList._refresh runs; every row re-reads its count and cost.
  6. add_to_resource("light", -cost) from step 3 already fired resource_changed("light", new_value); CounterLabel updated; every BuildingRow._refresh already ran (twice, once for resource_changed and once for building_purchased).
  7. 100 ms later, Tick.tick fires. GameState._on_tick(0.1) walks _income_rates, computes 0.1 × rate for each resource, calls add_to_resource(name, amount). Each add_to_resource emits resource_changed. Every BuildingRow._refresh runs again; every row's Buy button re-evaluates affordability.

Example

A player owning 30 Initiates clicks Buy on Aspirant (cost 100, current Light = 105). Step 3 deducts 100; Light = 5. Step 4 recomputes: 30 × 0.1 + 1 × 1.0 = 4.0 Light/s; with tick_multiplier = 1.0, _income_rates["light"] = 4.0. Step 7: 100 ms later, 4.0 × 0.1 = 0.4 Light added; Light = 5.4. After two seconds (twenty ticks), Light = 13. The new Aspirant cost is 100 × 1.15^1 = 115, so 13 Light is far from affording the second Aspirant — the cost curve dwarfs early income, exactly as the design intends.

purchase-to-income loop(or similar). The cache reflects the single owned Initiate. 7. Buy a second Initiate (cost 17)._income_rates["light"]becomes0.2. Tick rate doubles. Verify by watching Light increment visibly faster. 8. Buy a Sermon upgrade if affordable (M4.3 stack:tick_multiplier *= 1.5). After the purchase,_recalculate_incomere-walks:0.2 × 1.5 = 0.3. The cache reads0.3; tick adds0.03per tick; Light grows visibly faster again. The multiplier stacked into recompute, not into tick. 9. Stop the game. Openscripts/light.gdone more time and confirm the deleted code is gone — no orphan_on_tick, noTick.tick.connectin_ready. The Light autoload should be ~10–15 lines now:valueproperty with setter,value_changed` signal, that's it. 10. Save the project. Module 5 is complete — and so is the textbook's first major arc.

**What you have built.** Press F5 right now and you have an idle game. Click Train to earn Light. Buy an Initiate when you can afford one; watch it tithe 0.1 Light per second into your total. Buy a second; the rate doubles. Buy a Blessing or Sermon upgrade for a permanent click or tick multiplier. The numbers grow on their own, faster every time you make a decision. This is the genre's core loop, and it is now running in your project.

The next three modules deepen it. M6 adds the prestige meta-loop — when the run feels stagnant, pledge the Crusade and trade your run progress for permanent Honor that boosts every future run. M7 makes your progress survive a quit, and pays out the income you would have earned offline. M8 makes the numbers and the buttons look like a polished game. You have crossed the threshold from "building infrastructure" to "polishing a working game." The rest is finishing.

Self-check quiz

Q1 — _income_rates['light'] is 4.0. The next tick fires with tick_delta = 0.1. How much Light is added?

A. 4.0 — the rate is per tick. B. 0.4rate × tick_delta. C. 40.0 — the rate is per minute. D. 0.1 — the tick delta is the increment.

Reveal answer

B — _income_rates stores per-second rates; tick_delta is in seconds. The handler computes rate × tick_delta = 4.0 × 0.1 = 0.4. The unit convention is "rates are per-second" because that is how the design surfaces them ("0.1 Light per second per Initiate"); the tick can run at any interval and the math stays correct as long as tick_delta is the elapsed seconds. A is wrong: per-tick rates would couple the data to the tick frequency. C/D are unit-confusion answers.

Q2 — You forget to call _recalculate_income() inside purchase_building. The player buys an Initiate. What happens?

A. The Light counter stops ticking entirely. B. Light is correctly deducted; the count increments; but income does not increase — _income_rates["light"] still reflects the pre-purchase state. C. Income increases; Tick._process recomputes lazily. D. The next tick errors out because _income_rates doesn't have an entry for the new building.

Reveal answer

B — recompute-on-change is the contract. purchase_building mutates _buildings; without the recompute call, _income_rates is stale. The cache still has its previous value; the tick handler reads that previous value forever (or until the next purchase that does recompute). The Light counter still ticks (A wrong) but at the old rate. C invents reactivity (the engine doesn't recompute lazily for derived data; that is the dev's job). D is wrong: _income_rates is keyed by resource name, not building id, so the schema is unchanged.

Q3 — BuildingList._ready calls GameState.register_building(data) instead of writing to _building_lookup directly. Which property of the system is preserved by this refactor?

A. Performance — method calls are faster than dict writes. B. Encapsulation — the autoload owns its internal storage; callers go through a public method that can validate, log, or restructure later. C. Save compatibility — register_building writes to disk. D. Type safety — direct dict writes are untyped.

Reveal answer

B — encapsulation, the architectural property. The dict-write and the method call have indistinguishable runtime behavior in M5.4 — register_building is a one-liner. The benefit is the contract: callers write through register_building; if the storage shape changes (Array, custom Resource, lookup-with-validation), the callers don't change. A is wrong: method dispatch in GDScript is slightly slower than direct access, not faster. C invents persistence. D is wrong: Dictionary[String, BuildingData] is typed in either form.

Integration question

Q4 — open

Module 5 closes with the building economy fully wired. Trace one full second of game time from t = 0 to t = 1.0, with the player owning 10 Initiates and tick_multiplier = 1.0. List every signal emit, every method call, and every state mutation. How many resource_changed signals fire? How many times does each BuildingRow._refresh run?

Reveal expected answer

At t = 0 the state is steady: _income_rates["light"] = 1.0 (10 × 0.1 × 1.0). Tick.tick fires every 100 ms — so 10 fires across the second. Each tick:

  1. Tick.tick.emit(0.1) (1 emit).
  2. GameState._on_tick(0.1) runs. Walks _income_rates — 2 entries (light, honor). For light: amount = 0.1; calls add_to_resource("light", 0.1). For honor: amount = 0.0; skipped by the > 0.0 guard.
  3. add_to_resource mutates _resources["light"] and emits resource_changed("light", new_value) (1 emit).
  4. CounterLabel._on_resource_changed("light", v) runs. It updates text to "Light: " + str(int(v)). No further signal.
  5. Every BuildingRow._refresh runs (one per row, five rows in current build). Each row reads its count from _buildings (unchanged this tick), reads its cost via building.get_cost(count) (unchanged), reads GameState.get_resource("light") (changed by 0.1), updates Buy-button disabled state. Five method calls.

Per tick, total: 2 signal emits (tick, resource_changed), 1 add_to_resource, 1 CounterLabel update, 5 BuildingRow._refresh calls. Across the second: 10 ticks × 2 = 20 signal emits, 10 CounterLabel updates, 50 BuildingRow._refresh calls.

The Initiate's _refresh runs 10 times even though its own count didn't change — because Light changed, and the Buy button affordance depends on Light. This is fine: the refresh is microseconds. If profiling later showed it dominating frame time, the refresh handler could narrow to "only update labels that depend on the changed signal" — but at five rows, the broad-refresh is the readable choice.

The _refresh runs once per resource_changed, plus once per building_purchased (doesn't fire this second since no purchase happens). If a purchase happened mid-second, _refresh would run twice for the affected tick (once for resource_changed from the cost deduction, once for building_purchased).

This trace is the whole shape of an idle-game frame: cheap reads, cached rates, signals broadcasting state-deltas, UI refresh as a passive consequence of state changes. Module 5 closes here; Module 6 introduces a discontinuous mutation — prestige soft-reset — that breaks the steady-state assumption.

Glossary

Glossary

centralized tick handler
The single _on_tick(tick_delta) method on GameState introduced in M5.4 that walks _income_rates and adds per-resource amounts to _resources. Replaces the per-resource handlers from M3.1 (Light._on_tick). The convergence point of M3.3's multi-resource ledger and M5.3's per-resource rate cache.
register_building(data)
The public method on GameState that registers a BuildingData in _building_lookup. Introduced in M5.4 to replace M5.3's direct-dict-write from BuildingList._ready. Encapsulates the autoload's internal storage; callers depend on the method, not the dictionary's shape.
purchase-to-income loop
The full chain from Buy-button click to passive income arriving at the next tick: pressedpurchase_building_recalculate_incomeresource_changed + building_purchasedBuildingRow._refresh + CounterLabel update; then on the next tick: _on_tickadd_to_resourceresource_changed. The closure of Module 5; every subsequent module either modifies a multiplier (M6 prestige) or persists this state (M7 save/load).
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through what changed in Light.gd from M3.1 to M5.4. What was removed, why, and what stayed?` - `Trace one tick step-by-step with my actual _income_rates value. I'll paste it: {"light": 6.2, "honor": 0.0}.` - `What's the smallest test that would catch a forgotten _recalculate_income call in purchase_building?`