Output Integration with Tick¶
What you'll learn
- How the tick handler in
GameStatereads the_income_ratescache (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_lookupdirect 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_tickreads_income_rates→add_to_resource→resource_changedemits → 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_tickruns ten times per second. If it walked_buildings × _all_buildings_data × upgrade-multipliersevery 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_ratesexactly. There is no need to save_income_ratesitself — 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 multiplytick_multiplier. Building output (M5) multiplied bytick_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_ratesand adds. - Test surface. Income tests assert on
_recalculate_incomeagainst a fixed_buildingsshape — 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:
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:
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:
- The Buy button on
building_row.tscnemitspressed. BuildingRow._on_buy_pressedcallsGameState.purchase_building(building).purchase_buildingreads count, computes scaled cost, validates againstget_resource("light"), deducts, increments_buildings[id], calls_recalculate_income(), emitsbuilding_purchased, returnstrue._recalculate_incomewalks_buildings × _building_lookup, multiplies counts bybase_output, sums intototals["light"], appliestick_multiplier, writes to_income_rates.building_purchased.emit(data)fires;BuildingList._refreshruns; every row re-reads its count and cost.add_to_resource("light", -cost)from step 3 already firedresource_changed("light", new_value);CounterLabelupdated; everyBuildingRow._refreshalready ran (twice, once for resource_changed and once for building_purchased).- 100 ms later,
Tick.tickfires.GameState._on_tick(0.1)walks_income_rates, computes0.1 × ratefor each resource, callsadd_to_resource(name, amount). Eachadd_to_resourceemitsresource_changed. EveryBuildingRow._refreshruns 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.4 — rate × 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:
Tick.tick.emit(0.1)(1 emit).GameState._on_tick(0.1)runs. Walks_income_rates— 2 entries (light,honor). Forlight: amount = 0.1; callsadd_to_resource("light", 0.1). Forhonor: amount = 0.0; skipped by the> 0.0guard.add_to_resourcemutates_resources["light"]and emitsresource_changed("light", new_value)(1 emit).CounterLabel._on_resource_changed("light", v)runs. It updatestextto"Light: " + str(int(v)). No further signal.- Every
BuildingRow._refreshruns (one per row, five rows in current build). Each row reads its count from_buildings(unchanged this tick), reads its cost viabuilding.get_cost(count)(unchanged), readsGameState.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 onGameStateintroduced in M5.4 that walks_income_ratesand 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
GameStatethat registers aBuildingDatain_building_lookup. Introduced in M5.4 to replace M5.3's direct-dict-write fromBuildingList._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:
pressed→purchase_building→_recalculate_income→resource_changed+building_purchased→BuildingRow._refresh+CounterLabelupdate; then on the next tick:_on_tick→add_to_resource→resource_changed. The closure of Module 5; every subsequent module either modifies a multiplier (M6 prestige) or persists this state (M7 save/load).