Income Recalculation on Purchase¶
What you'll learn
- The cost asymmetry between recompute on every tick and recompute on every input change, and why idle-game economies fall on the second side of the line.
- The recompute-on-change pattern: every code path that mutates an input to the income calculation calls
_recalculate_income()after the mutation. - The dirty-flag alternative: mutations set a flag, the recompute happens once at the start of the next tick. When this is preferable.
- How to author
Light.add_generator(rate)and_recalculate_income()so that future M4 upgrades and M5 buildings can plug into the same recompute path with no changes to the tick handler. - Why income should be a cached property derived from inputs, not a primary property the player or the system writes to directly.
How it applies
- Long-session arithmetic cost. A player with one hundred buildings, ten upgrades affecting income, and a 10 Hz tick triggers a hundred-multiplied-by-eleven O(N) recompute one thousand times per second if income is computed inside
_on_tick. Recompute-on-change collapses that to "once per purchase" — typically a few hundred recomputes per multi-hour session, instead of millions. - Determinism for replays and offline calc. When M7 implements offline earnings, the calculation is
(seconds idle) × cached_cps. Ifcached_cpsis computed-on-change, the cached value at the moment the player closed the game is exact and reproducible. Ifcpsis computed inside the tick, the offline-earnings calculation has to walk every input as of that snapshot, then assume "no inputs changed during the offline period" — true, but the architecture does not enforce it. - Save-system simplicity. Save serializes
_generators(the inputs) and reconstructslight_per_secondon load by calling_recalculate_income()once. Saving the output (light_per_seconddirectly) would risk a save where the cached output disagrees with the inputs; loading it would silently re-cache the broken value. - Live tuning by designers. A designer or QA tester opens the Remote tab, edits a generator's rate, and the income updates immediately on the next purchase or input change. The single recompute path is the integration point for any tuning surface — Inspector edits, debug commands, mod hooks all converge there.
- Modding and observability. A mod that wants to add a "global income multiplier" hooks
_recalculate_income()(overrides or wraps it). All income flows through the same method; a single hook is enough. Adding the hook inside the tick callback would catch runtime ticks but miss purchase-time recomputes — observable behavior would diverge from steady-state.
Concepts¶
The cost of recompute-per-tick¶
The naive way to compute light_per_second is "look at every contributing source, sum them up, on every tick." For a current-state shape with a handful of generators:
func _on_tick(tick_delta: float) -> void:
var cps: float = 0.0
for rate in _generators:
cps += rate
value += cps * tick_delta
This is correct for the result. It is wrong for the bookkeeping: every tick, the engine walks the full generator list, even though the list only changes when the player buys, sells, prestiges, or loads a save. At 10 Hz with one hundred generators, that is one thousand per-tick array accesses, ninety-nine percent of which produce the same total as the previous tick.
The asymmetry is fundamental to idle games. Inputs (purchases, upgrades, prestiges) are rare — typically minutes apart, hundreds of events per multi-hour session. Output consumption (the tick) is constant — ten emits per second, hundreds of thousands per session. Putting computation on the constant side of that asymmetry is the architectural mistake the patterns below correct.
Example
A late-game player has one hundred fifty generators across five tiers, each with three multiplier upgrades. Per-tick recompute walks one hundred fifty rates with multipliers — call it five hundred multiplications per tick at 10 Hz, five thousand per second. Per session of three hours that's fifty-four million multiplications for nothing — the result was already computed during the last purchase ten minutes ago and has not changed since.
Recompute-on-change (push)¶
Recompute-on-change requires that every code path which mutates an input must, before returning, call the derivation. The cached output (light_per_second) is always current; readers (the tick handler) just access the cache.
Adding a generator triggers exactly one recompute. Removing one (M5 building sells) triggers exactly one. Buying an upgrade that multiplies output (M4) triggers exactly one. The tick handler does no math beyond value += cached × delta.
The discipline is every mutation path. Forgetting one means stale cache: a generator added but the cache untouched, the player sees no income from the new generator until the next purchase happens to refresh it. Treat _recalculate_income() as part of every mutation method's contract; the recompute is not optional.
Example
A new code path is added: func grant_emergency_generator(rate: float) for a debug command. The author writes _generators.append(rate) and forgets the recompute call. The generator is in the list (visible in the Remote tab) but light_per_second is unchanged; the new generator contributes nothing until the player makes another purchase. The bug is invisible until QA notices the rate did not jump, and the diagnosis is "every mutation must call recompute, this path forgot." The textbook's answer is to keep the mutation methods small and review them as a set whenever the input shape changes.
Dirty-flag (pull-deferred)¶
The dirty-flag alternative inverts the timing. Mutations set a flag; the recompute happens lazily, at the start of the next tick (or first read after the flag flips).
var _income_dirty: bool = true
func add_generator(rate: float) -> void:
_generators.append(rate)
_income_dirty = true
func _on_tick(tick_delta: float) -> void:
if _income_dirty:
_recalculate_income()
_income_dirty = false
value += light_per_second * tick_delta
This pattern wins when many mutations happen in rapid succession — say, a "buy ×100" button that adds one hundred generators in one frame. Recompute-on-change would run the recompute one hundred times. Dirty-flag runs it once on the next tick. The cost is a one-tick lag (negligible at 10 Hz) and slightly more state to track.
For the textbook's project, mutation rate is low enough that recompute-on-change wins on simplicity. M5 will not introduce buy-×N until late; M6's prestige will fire one mutation. The dirty-flag pattern is left as a reference for projects with bulk-mutation surfaces.
Example
A "Sermon of Mass Recruitment" upgrade in M4 grants ten generators in a loop. With recompute-on-change, ten recomputes fire — wasted work, but correct. With dirty-flag, the loop sets the flag ten times (cheap) and one recompute fires on the next tick. The math is identical; only the temporal distribution differs. Until the upgrade exists and is used, the difference is invisible — which is why this textbook does not adopt the dirty-flag now and pays the small simplicity dividend.
Cached output as a derived property¶
light_per_second is no longer something the player or system writes to. It is derived from _generators and (eventually) the upgrade list. The right mental model: _generators is primary state (what the player owns); light_per_second is cached derivation (what those things produce per second).
This distinction matters for save format. M7's serializer writes the primary state — value, _generators, the future _upgrades and _buildings dictionaries. On load, the serializer recomputes the derivation by calling _recalculate_income() once. light_per_second is never serialized; it is always recomputable from inputs. Saving it would create a class of bug ("save has stale cps") that the design has eliminated by only saving the inputs.
Example
A save file from an old version of the game stores light_per_second = 12.5. The new version's recompute would produce light_per_second = 14.0 (a balance change increased one generator's rate). Loading the save and trusting the stored cps would lock the player into the old rate. Loading the save and recomputing from _generators produces the new rate automatically — balance changes propagate to existing saves without migration code. The only state the save must keep is the inputs.
_recalculate_income() is the integration point¶
Every future feature that affects income passes through _recalculate_income():
- M4 upgrades that multiply click value or building output read the upgrade list and apply multipliers in the recompute.
- M5 buildings stored as
_buildings: Dictionaryofid → countare walked, their per-unit rate looked up fromBuildingData, multiplied by count, summed. - M6 prestige multipliers from
Honorare applied as a final scalar in the recompute.
The method is the project's single source of truth for "given the current state of every input, what is the cached output?" Treating it as a contract — pure, readable, easy to test in isolation — pays off across modules. Tests can call it directly with mocked inputs and assert the output; live debugging in the Remote tab can call it after editing inputs and watch the cache update.
Example
A new mechanic in M6 — "Crusade Bonus" — adds a +10% global multiplier when the player has prestiged at least once. The implementation is one line in _recalculate_income(): if Honor.prestige_count > 0: total *= 1.10. No tick-handler change, no UI change, no save-format change. The income system absorbed the new mechanic at exactly one location.
Walkthrough¶
You will perform these in your own Godot editor. Coming in, Light has a tick handler that adds light_per_second × tick_delta per emission, and light_per_second is a plain field defaulted to 0.0.
- Open
scripts/light.gd. Below thelight_per_seconddeclaration, add a backing list of contributing generator rates and the recompute method. The fragment:var _generators: Array[float] = [] func _recalculate_income() -> void: var total: float = 0.0 for rate in _generators: total += rate light_per_second = totalArray[float]is GDScript's typed-array syntax — the array is constrained to hold floats, mismatched values raise at insert time. Thefor rate in _generatorsloop reads as "for each value in the array, naming itrate." The total is assigned tolight_per_secondat the end; readers (the tick handler) see the update on the next emit. - Add the mutation method that appends a rate and triggers recompute: Two lines, in order. Forgetting either is a bug; calling them in reverse order means the recompute runs before the new generator is in the list, so the cache is stale. Recompute-after-mutation is the discipline.
- To verify the path end-to-end without M4 or M5 yet, in
Light._ready()(the existing method), append a single line after theTick.tick.connect(...)line:add_generator(2.0). This installs one generator producing two Light per second; the recompute runs immediately,light_per_secondbecomes2.0, and the next tick adds2.0 × 0.1 = 0.2per fire (two per second). This is a verification probe, not production code; step 7 removes it. The first realadd_generatorcall lives in M5, when buying an Initiate adds the building'sbase_outputto the income chain. - Save (
Ctrl+S). PressF5. The counter Label starts atLight: 0and ticks:0,0, ... wait through five ticks (half a second), then1, then again half a second, then2. The integer increments by one each two ticks because the%dformat truncates0.2 → 0,0.4 → 0,0.6 → 0,0.8 → 0,1.0 → 1. Click the train button while ticking: the value jumps by1.0(clicks) on top of+0.2(ticks), confirming both write paths flow through the same setter and the same Label listener. - Stop the game. Open the Remote tab during the next launch — find
/root/Lightand observelight_per_secondin the Inspector reading2.0. Edit_generators(it appears as an inspectable Array): change the first entry from2.0to5.0. The Inspector update does not call the setter forlight_per_second— the cached value is still2.0, and the next tick still adds0.2. This is the bug class recompute-on-change is designed to prevent at code-level mutations; an out-of-band edit in the Remote tab bypasses the discipline. To fix, call_recalculate_income()from the Remote tab's REPL, or trigger any code path that calls it. - Add a sanity-check method that demonstrates re-running recompute manually. Below
add_generator, add: No production caller; available from the Remote tab's REPL as/root/Light.debug_recompute(). After editing_generatorsin the Inspector, calling this method once updateslight_per_secondto match. The textbook's preferred discipline is "do not edit_generatorsdirectly; useadd_generator" — but the debug method makes the manual case observable. - Stop the game. Remove the test
add_generator(2.0)line from_ready()— the clean state for M3.3 is "passive income exists as a plumbed path, the recompute method exists as an integration point, but no generators are installed yet." M5's BuildingData will be the first real source. - Save the script. The visible state is identical to end-of-M3.1: clicks add
1.0, ticks add0.0(no generators), the counter readsLight: <click count>.
Optional sanity check. Add a
_recalculate_income(). Run, click the train button — recompute does not print. The click handler writes throughvalue's setter, which firesvalue_changed; it does not touch the income calculation. Now callLight.add_generator(1.0)from the Remote tab REPL — recompute prints once. The print confirms the recompute fires only on input changes, never on output reads. Remove the print.
Self-check quiz¶
Q1 — A 'Buy ×10 buildings' button calls add_generator(rate) ten times in a loop within a single frame. How many times does _recalculate_income() run under the recompute-on-change pattern, and how many under the dirty-flag pattern?
A. Recompute-on-change: 1. Dirty-flag: 1. B. Recompute-on-change: 10. Dirty-flag: 1. C. Recompute-on-change: 10. Dirty-flag: 10. D. Recompute-on-change: 1. Dirty-flag: 10.
Reveal answer
B — recompute-on-change runs once per mutation; dirty-flag coalesces. Recompute-on-change calls _recalculate_income() inside every add_generator call — ten calls means ten recomputes. Dirty-flag sets _income_dirty = true ten times (cheap boolean writes, all set to the same value), and the next _on_tick runs the recompute exactly once before clearing the flag. The asymmetry is the design lever: dirty-flag wins when bulk mutations are common, recompute-on-change wins when mutations are rare and you don't want to think about flag management. A is wrong: recompute-on-change does not coalesce. C is wrong: dirty-flag is a coalescing pattern. D inverts the relationship.
Q2 — You forget to call _recalculate_income() inside add_generator() (the recompute-on-change discipline). The player buys a generator. What does the player observe?
A. The Light counter immediately ticks faster — the engine auto-recomputes when _generators changes.
B. The counter ticks at the old rate; the new generator contributes nothing until some other code path triggers a recompute.
C. The game crashes on the next tick because _generators and light_per_second are out of sync.
D. The setter for light_per_second detects the staleness and emits an error to the Output panel.
Reveal answer
B — silently stale cache. GDScript does not auto-recompute on array mutations. _generators has a new entry but light_per_second is whatever the cache last held. The tick handler reads the stale cache and produces the old rate. The bug is invisible until some unrelated code path (another purchase, a save reload) calls _recalculate_income() and refreshes the cache — at which point the player sees a sudden jump in income that lags the originating action by minutes. The discipline of "every mutation calls recompute" is mandatory because the engine does not enforce it. A imagines reactive auto-recompute that does not exist. C is wrong: out-of-sync caches do not crash, they silently mislead. D fabricates an error path.
Q3 — The save file format from M7 will store _generators and _value but not light_per_second. Why?
A. light_per_second is too large to fit in a JSON float.
B. light_per_second is derivable from _generators (and future inputs) by calling _recalculate_income() once on load — saving it would risk a stale cache surviving to the next session.
C. The Godot save system can only serialize fields with @export.
D. light_per_second is private (underscore convention) and cannot be serialized.
Reveal answer
B — derived state is reconstructed from inputs on load. The architectural rule is "save inputs, recompute outputs." Saving the output creates a class of bug where the cached value disagrees with the recomputation — typically because game logic changed between sessions (rebalances, bug fixes) and the saved output reflects the old logic while inputs reflect the new one. Skipping it forces the load path to recompute, which produces the canonically correct value for the current code. A is wrong: floats serialize fine. C confounds @export with serialization; M7's saver walks fields directly. D is wrong: GDScript's underscore is convention, not access control; the saver could include light_per_second if it wanted to, the design choice is to refuse.
Integration question¶
Q4 — open
In M3.1 you wired Light to subscribe to Tick.tick. In M3.2 you added _recalculate_income() and add_generator(rate). The tick handler now reads a cached light_per_second. Trace the sequence of method calls and signal emits from the moment a player calls Light.add_generator(3.0) to the moment the counter Label displays a higher value. How many signals fire, and what is each one's job?
Reveal expected answer
add_generator(3.0) runs _generators.append(3.0), then calls _recalculate_income(). Recompute walks the array, sums the rates, assigns the total to light_per_second. No signal fires from the recompute itself — light_per_second is a plain field with no setter, no signal. The cache is updated silently. On the next Tick.tick emit (within at most TICK_INTERVAL seconds), the handler _on_tick(tick_delta) reads the new light_per_second and writes through value += light_per_second * tick_delta. The setter on value fires value_changed.emit(_value), which the Label's listener _on_light_changed(new_value) receives, which sets counter_label.text. So the visible flow is: one method call (add_generator) → one recompute (silent) → one tick emit (next tick) → one value-changed emit (per tick) → one label update. The deeper architectural property: input changes do not directly notify the UI. They update the derivation, and the output's signal (the tick-driven setter on value) carries the change to the UI. This is intentional: a player who buys a generator does not need an instant UI flash; the next tick will reflect the higher rate, and that is fast enough on a 10 Hz timeline. If instant UI feedback for the purchase event itself is wanted (M5 will want this), it is a separate signal — Light.income_changed emitted from _recalculate_income(), distinct from value_changed.
What's next¶
The recompute-on-change discipline is established and the _generators array can hold rates. M3.3 generalizes this from one-resource (Light) to many-resource (Light + Honor + anything later), then M5 fills the array with real building rates.
Glossary¶
Glossary
- recompute-on-change
- A pattern where any code path that mutates an input to a derived value re-runs the derivation once, immediately, before returning. The cached output is always current. Right when input changes are rare and output reads are frequent.
- dirty-flag
- A pattern where mutations set a boolean (
_income_dirty = true) but defer the actual recompute until the cache is next read. Coalesces multiple mutations in the same frame into one recompute. Right when many small mutations may occur in rapid succession; overkill when mutations are rare. - derived property / cached derivation
- A property whose value is computed from other properties (the inputs), not stored as primary state. Saved-and-restored by reconstructing from inputs, not by serializing the cache. The architectural distinction between "what the player owns" (primary) and "what those things produce per second" (derived).
- typed array (
Array[T]) - GDScript's typed-array syntax. The array enforces its element type at insert time —
Array[float]rejects non-floats with a runtime error. Distinct from untypedArray, which allows mixed types but loses static type-checking.