Skip to content

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. If cached_cps is computed-on-change, the cached value at the moment the player closed the game is exact and reproducible. If cps is 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 reconstructs light_per_second on load by calling _recalculate_income() once. Saving the output (light_per_second directly) 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.

func add_generator(rate: float) -> void:
    _generators.append(rate)
    _recalculate_income()

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: Dictionary of id → count are walked, their per-unit rate looked up from BuildingData, multiplied by count, summed.
  • M6 prestige multipliers from Honor are 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.

  1. Open scripts/light.gd. Below the light_per_second declaration, 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 = total
    
    Array[float] is GDScript's typed-array syntax — the array is constrained to hold floats, mismatched values raise at insert time. The for rate in _generators loop reads as "for each value in the array, naming it rate." The total is assigned to light_per_second at the end; readers (the tick handler) see the update on the next emit.
  2. Add the mutation method that appends a rate and triggers recompute:
    func add_generator(rate: float) -> void:
        _generators.append(rate)
        _recalculate_income()
    
    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.
  3. To verify the path end-to-end without M4 or M5 yet, in Light._ready() (the existing method), append a single line after the Tick.tick.connect(...) line: add_generator(2.0). This installs one generator producing two Light per second; the recompute runs immediately, light_per_second becomes 2.0, and the next tick adds 2.0 × 0.1 = 0.2 per fire (two per second). This is a verification probe, not production code; step 7 removes it. The first real add_generator call lives in M5, when buying an Initiate adds the building's base_output to the income chain.
  4. Save (Ctrl+S). Press F5. The counter Label starts at Light: 0 and ticks: 0, 0, ... wait through five ticks (half a second), then 1, then again half a second, then 2. The integer increments by one each two ticks because the %d format truncates 0.2 → 0, 0.4 → 0, 0.6 → 0, 0.8 → 0, 1.0 → 1. Click the train button while ticking: the value jumps by 1.0 (clicks) on top of +0.2 (ticks), confirming both write paths flow through the same setter and the same Label listener.
  5. Stop the game. Open the Remote tab during the next launch — find /root/Light and observe light_per_second in the Inspector reading 2.0. Edit _generators (it appears as an inspectable Array): change the first entry from 2.0 to 5.0. The Inspector update does not call the setter for light_per_second — the cached value is still 2.0, and the next tick still adds 0.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.
  6. Add a sanity-check method that demonstrates re-running recompute manually. Below add_generator, add:
    func debug_recompute() -> void:
        _recalculate_income()
    
    No production caller; available from the Remote tab's REPL as /root/Light.debug_recompute(). After editing _generators in the Inspector, calling this method once updates light_per_second to match. The textbook's preferred discipline is "do not edit _generators directly; use add_generator" — but the debug method makes the manual case observable.
  7. 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.
  8. Save the script. The visible state is identical to end-of-M3.1: clicks add 1.0, ticks add 0.0 (no generators), the counter reads Light: <click count>.

Optional sanity check. Add a print at the top of _recalculate_income(). Run, click the train button — recompute does not print. The click handler writes through value's setter, which fires value_changed; it does not touch the income calculation. Now call Light.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 itselflight_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 signalLight.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 untyped Array, which allows mixed types but loses static type-checking.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through what happens when I call add_generator(5.0). Show me which lines run, in order, and what the values of _generators and light_per_second are after each.` - `Compare recompute-on-change with React's "lift state up + derive" pattern. Where do they agree, where do they differ?` - `Show me how the dirty-flag pattern would extend to handling 'income changed' signals so M5 buildings can flash a UI badge on purchase.`