Tick Autoload & Accumulator¶
Try it first
You have a _process(delta) function that runs every frame. On a 60 Hz monitor that's 60 calls per second; on a 144 Hz monitor, 144 calls per second; on a frame the OS dropped, maybe 0.05 s elapses between calls.
You want the player to receive exactly 1 Light per second of passive income, regardless of frame rate. The amount per call depends on how often _process runs.
Spend 3–4 minutes thinking about how you would decouple "1 Light per second of real time" from "however often _process happens to fire." Write down the shape of the solution before reading on. There are at least two reasonable approaches; the chapter shows the canonical one and explains why it wins.
What you'll learn
- The
_process(delta)lifecycle method and the meaning of thedeltaparameter the engine passes in. - Why a 60-Hz
_processcallback is the wrong substrate for idle-game passive income, and what an accumulator does to fix it. - How to author a
Tickautoload that emits atick(delta)signal at a fixed cadence — independent of frame rate, hardware, or window focus. - The difference between frame rate (variable, hardware-bound) and tick rate (fixed, design-bound), and why every per-second mechanic in the game routes through the latter.
- How to subscribe
LighttoTick.tickso the player's Light total grows bylight_per_second × tick_deltaon every tick — even when no one is clicking.
How it applies
- Determinism across hardware. A passive-income loop driven by raw
_process(delta)runs sixty times per second on a desktop and ninety on a 90-Hz handheld. Any discretization in the per-call math (rounding, threshold checks, popup spawns) drifts between machines. A fixed-cadence tick — samedeltaevery fire — produces the sameLighttotal per minute on every machine. QA can write "after 60 seconds of idle, Light total should be 60×CPS ± 0.01" and have the test pass on a Steam Deck and a 4K desktop. - Save longevity / offline earnings. When M7 implements offline earnings, the calculation is
(seconds elapsed since save) × cps. With tick-based income, the calculation is exact: the sametick(0.1)that fires sixty times per minute when running can be replayed mathematically when restoring. No "average framerate during the last session" hand-waving. - Battery and thermals on handhelds. A
_processthat does any per-frame work — even cheap work — wakes the CPU at 60 Hz. On a Steam Deck with the game tabbed out, this is wasted battery. A tick autoload that emits at 10 Hz (TICK_INTERVAL = 0.1) cuts the wakeup count by 6×. The frame-rendering loop still runs (the engine cannot skip frames the OS asks for), but the gameplay loop is paced by tick, not by frame. - Animation versus simulation separation. The Tween animation in M8 will run on the frame loop (smooth interpolation, no quantization). The Light counter increments on the tick loop (discrete, predictable). Mixing the two — running animations on the tick — would produce jittery 10-Hz interpolation, which players read as "lag." Mixing the other way — running income on the frame loop — produces variable-rate gameplay, which speedrunners exploit.
- Design-knob accessibility.
TICK_INTERVALis one constant. Halving it (10 Hz → 20 Hz) doubles the resolution of the income graph at the cost of doubling the emit count. Every per-second mechanic in the game subscribes toTick.tick; the constant is the single tunable that controls the texture of progress. A designer or QA tester can experiment with this in the source without touching gameplay code.
Concepts¶
Two ways to get rate-correct income, and why one wins¶
You probably reached one of two answers:
- Multiply by
delta.Light.value += rate * deltainside_process. The total Light per second is correct (rate-times-real-time) regardless of frame rate. This is almost right. - Accumulate elapsed time, fire on threshold. Keep a running
_accumulator += delta; when it crosses a fixed interval (say 0.1 s), emit a discrete "tick" and subtract the interval. This is the canonical answer.
Both produce rate-correct income. The accumulator wins on three counts that don't show up until you scale: emit count (60 vs 10 per second per resource — the setter and every connected listener fires that many times); save-replay correctness (a fixed-cadence tick replays losslessly; a frame-rate-dependent integration accumulates rounding error); and design-knob accessibility (one constant — TICK_INTERVAL — controls the texture of progress for every later mechanic). The rest of the chapter names those three reasons in detail and shows you the pattern that gets all three for free.
_process(delta) — the per-frame callback¶
_process(delta) is the engine's per-frame heartbeat. The engine calls it on every Node that overrides it, once per rendered frame, passing the seconds elapsed since the previous call as delta. On a 60 Hz monitor, delta is approximately 0.01667 (sixty calls per second). On a 144 Hz monitor it is ≈ 0.00694 (one hundred forty-four calls per second). On a frame the OS dropped, it could be 0.05 (one twentieth of a second since the last call).
_process is the right place for animation, smooth camera movement, and continuous input polling — work that should run as often as the screen refreshes. It is the wrong place for any logic whose effect-per-second should be invariant across hardware.
Example
A naive passive-income line — Light.value += 1.0 inside _process(delta) — adds Light at the frame rate, not at one-per-second. Sixty Light per second on a 60 Hz desktop, one hundred forty-four per second on a 144 Hz monitor. The fix is multiplying by delta: Light.value += 1.0 * delta adds one Light per second regardless of frame rate. This is correct but still calls the setter (and emits the signal) sixty times per second on the 60 Hz machine, with sub-pixel UI updates between them.
Why fixed-cadence ticks beat per-frame increments¶
Multiplying by delta in _process is correct for total-Light-per-second but wrong for everything else. Three problems compound:
- The setter fires at frame rate. Sixty UI signal emits per second per resource per machine. The Label re-renders every frame even when the visible integer hasn't changed.
- Every consumer (achievement counter, sound-effect threshold, popup spawn) runs at frame rate too. Idle games have many such consumers; they multiply.
- The math is pseudo-continuous (
+= 0.0167 × cps). Save-and-restore, offline earnings, and replays must reproduce a sequence of small floats with rounding. Tick-based math (+= 0.1 × cps, ten times per second) accumulates fewer rounding errors and is easier to verify by hand.
A tick is a fixed-cadence event. The Tick autoload runs its own _process callback, but instead of doing work directly, it accumulates the elapsed time and emits a tick(TICK_INTERVAL) signal whenever a full interval has passed. Subscribers receive a constant delta (always TICK_INTERVAL, never the variable frame delta) at a constant rate.
Example
You set TICK_INTERVAL = 1.0 (one tick per second). Light's _on_tick(delta) runs once per second with delta = 1.0. The Label updates once per second; the counter ticks visibly. You set TICK_INTERVAL = 0.1 (ten ticks per second). The Label updates ten times per second; the counter feels smooth but still discrete. You set TICK_INTERVAL = 0.01 (a hundred ticks per second). The Label appears continuous but emits hundred-per-second now have no visible benefit, only cost. The textbook uses 0.1 as the design default — granular enough to feel alive, coarse enough to keep emit count modest.
The accumulator pattern¶
The accumulator is the bridge between variable-rate _process and fixed-rate ticks. The variable holds a running total of time owed:
var _accumulator: float = 0.0
func _process(delta: float) -> void:
_accumulator += delta
while _accumulator >= TICK_INTERVAL:
tick.emit(TICK_INTERVAL)
_accumulator -= TICK_INTERVAL
The while loop, not an if, is load-bearing. On a frame where the OS dropped a chunk of time (a 100 ms hitch), delta could be 0.1 and the accumulator could accumulate two or three full intervals at once. The while drains the accumulator, firing one tick per interval until it is below threshold. Subscribers receive the right number of ticks even when frames are uneven; they just receive them in a burst on the next clean frame.
_accumulator -= TICK_INTERVAL (subtract) is preferred over _accumulator = 0 (reset). Subtracting preserves any sub-interval remainder for the next frame. Resetting to zero would discard the remainder and make the long-run rate slightly slow — a 0.1 s tick would actually take 0.1 s + (avg frame time) per emit. For ten ticks per second over an hour, that drift compounds.
Example
Frame n has delta = 0.07. The accumulator was 0.05 from previous frames. After += delta, the accumulator is 0.12. The loop body fires once (one tick emit), the accumulator becomes 0.02. Frame n+1 has delta = 0.20 (a hitch). After +=, accumulator is 0.22. The loop body fires twice (two emits), accumulator becomes 0.02. Two ticks of compensation in one frame, no work lost, no work double-counted.
Tick versus _physics_process¶
Godot has a built-in fixed-cadence callback called _physics_process(delta), which the engine fires at a configurable physics rate (default 60 Hz). It is fixed-cadence by design — delta is always 1 / physics_fps. You could use it as your tick source. The textbook chooses a custom Tick autoload over _physics_process because:
- Tick rate is decoupled from physics rate. A platformer at 60 Hz physics has different scaling concerns than an idle game at 10 Hz income ticks. Sharing the constant means changing one breaks the other.
- A custom autoload is readable and inspectable. You can attach print statements, profile per-tick cost, swap it out with a paused-tick mock for testing.
_physics_processis the engine's, and a misbehaving handler hides behind engine internals. - Pause behavior is explicit. When M6 introduces prestige and you want passive income to pause during a confirmation dialog, you call
Tick.set_process(false)once. Pausing_physics_processfor one node would also halt physics for other nodes that rely on it.
For a pure idle game with no physics, _physics_process would work. The textbook uses a custom Tick autoload because the patterns generalize: M6 will pause it, M7 will replay it from a save, M8 will throttle UI updates against its rate.
Example
A physics game (a platformer plus an idle layer) wants 60 Hz physics for the platformer and 10 Hz income for the idle layer. Sharing _physics_process forces the idle layer to either run at 60 Hz (six times more emits than needed) or to gate every callback on a "should I tick?" check. A separate Tick autoload at 10 Hz keeps the two systems' rates independent. Idle ticks are not physics ticks; they should not be coupled.
Walkthrough¶
You will perform these in your own Godot editor. The M2.3 click flow should still work: clicking TrainButton increments Light.value, the counter Label updates.
- In the FileSystem dock, right-click
scripts/→ New → Script. Name ittick.gd. Click Create. - Replace the body. The full code-fragment ceiling for this chapter:
Ten logical lines, one fragment, unsplittable. Note there is deliberately no
extends Node signal tick(delta: float) const TICK_INTERVAL: float = 0.1 var _accumulator: float = 0.0 func _process(delta: float) -> void: _accumulator += delta while _accumulator >= TICK_INTERVAL: tick.emit(TICK_INTERVAL) _accumulator -= TICK_INTERVALclass_name Tick: this script becomes theTickautoload in step 3, and in Godot 4.6 aclass_namewhose name matches an autoload's name raises the error "Class 'Tick' hides an autoload singleton." The autoload registration is what makesTicka global name reachable asTick.tick;class_nameis for scripts you reference as a type (like theBuildingDataResource in M5), not for autoloads. The constant declares the cadence; the accumulator state lives across frames; the_processbody is the engine integration. Save (Ctrl+S). - Register Tick as an autoload.
Project → Project Settings → Globals → Autoload. Path:res://scripts/tick.gd. Node Name:Tick. Click Add. The autoload list now has two rows:LightandTick. DragTickaboveLightin the list. The reason: in step 5 below,Light._ready()will subscribe toTick.tick. For that subscription to work,Tickmust already exist as a global identifier whenLight._ready()runs — and the engine instantiates autoloads in list order, so earlier-listed autoloads complete_ready()before later-listed ones can reference them. If you've already moved past this step andLightis aboveTick, fix the order now: drag in the autoload list. Close the dialog. - Open
scripts/light.gd. Add a new property — the per-second income rate — below the existing_valueand property. The fragment: No setter, no signal. Treated as a plain field for now; M3.2 will add the recompute discipline that decides when this value changes. For M3.1 we will set it manually in step 8 to verify the tick path works. - Add a
_ready()method tolight.gdif one is not already there, and connect toTick.tick:Tickresolves to the autoload's global name; the connect is identical to M2.3'sLight.value_changed.connect(...)pattern, just with a different publisher. - Add the handler. The handler computes
light_per_second × tick_deltaand writes throughvalue's setter — which clamps and emitsvalue_changed, exactly as a click would: The parameter is namedtick_deltato avoid shadowing the GDScript built-indelta(which is the parameter name_processuses). Same value, different name — readability over identifier collision. - Save (
Ctrl+S). PressF5. The counter Label still saysLight: 0. ClickTrainButtononce: it ticks to1. Click again:2. Same as M2.3 — passive income is0so far, only clicks register. - Stop the game. In
light.gd, change the default oflight_per_secondfrom0.0to5.0. Save. PressF5. The counter starts at0and ticks:0,0,1,1,2, ... Wait — what's happening?light_per_second = 5.0andtick_delta = 0.1, so each tick adds0.5. The displayed integer (%dtruncates thefloat) ticks every other tick. After ten seconds the value is50.0; the label readsLight: 50. Click the button while it's running — clicks add1.0immediately, ticks add0.5ten times per second. The two sources interleave smoothly because both flow through the same setter and the samevalue_changedsignal. The5.0test value will be removed in the next step — the production state for M3 is "passive income plumbed but zero." Real generators arrive in M5, when buildings begin to produce Light per second on purchase. - Stop the game. Reset
light_per_secondto0.0and save. The clean state for M3.2 is "passive income exists as a plumbed path but is zero until something — buildings, in M5 — makes it nonzero."
Optional sanity check. Add
print("tick ", _accumulator)as the first line ofTick._processand re-launch. The Output panel logs the accumulator value at every frame call. On a 60 Hz monitor expectdelta ≈ 0.0167per call and the accumulator ramping from0to0.1over six frames before resetting. Any sustained accumulator growth (e.g., always above0.1) means the loop is not draining — check that thewhileiswhile, notif. Remove the print when satisfied.
Self-check quiz¶
Q1 — TICK_INTERVAL is 0.1. The OS hitches and frame n arrives with delta = 0.35 after a long pause. The accumulator was 0.04 before this frame. How many tick.emit calls fire during this _process, and what is the accumulator value after?
A. One emit; accumulator becomes 0.29.
B. Three emits; accumulator becomes 0.09.
C. Three emits; accumulator becomes 0.05.
D. Zero emits; the engine clamps delta to 0.05 to prevent spiral-of-death.
Reveal answer
B — three emits, accumulator becomes 0.09. After _accumulator += delta: 0.04 + 0.35 = 0.39. The while body fires repeatedly: emit, 0.39 − 0.1 = 0.29, emit, 0.29 − 0.1 = 0.19, emit, 0.19 − 0.1 = 0.09, loop test fails (0.09 < 0.1), exit. Three emits dispatched in one _process; the remainder 0.09 carries forward to the next frame. A is wrong: it confuses if with while (one emit instead of draining). C arithmetic-error: 0.39 − 3 × 0.1 = 0.09, not 0.05. D fabricates a Godot delta-clamp: Godot does have a max_physics_steps_per_frame for _physics_process, but _process(delta) is not capped; you handle hitches yourself.
Q2 — You write the income line directly inside _process instead of a tick handler: Light.value += light_per_second * delta. The visible behavior is 'Light grows at light_per_second per real-world second on any monitor.' So why does the textbook prefer the tick approach?
A. The tick approach is faster — _process is more expensive than handling a signal.
B. _process updates produce sixty UI re-renders per second per resource regardless of visible change; tick produces ten, throttling the display path.
C. _process cannot read light_per_second; only ticks can access autoloaded state.
D. The tick approach is required by the engine's signal system.
Reveal answer
B — display-path throttling and emit-count economy. The total Light per real-world second is identical under both approaches (* delta makes it frame-rate-invariant). The difference is how many times per second the value-change signal fires. With _process, the setter runs at frame rate (60+ per second). With ticks, ten per second. Each emit fans out to every Label, sound effect, achievement listener — multiply by the listener count to see the cost difference. A is wrong on direction: tick adds an indirection cost, but the savings on listener fanout dominate. C is fabricated. D is wrong: the signal system works fine in either pattern; the choice is design, not requirement.
Q3 — Your _process body uses _accumulator = 0 instead of _accumulator -= TICK_INTERVAL at the end of the while body. After ten minutes of running, the player's Light total is consistently lower than TICK_INTERVAL × tick_count × cps. Why?
A. Resetting to zero discards the sub-interval remainder; over many frames the remainders sum to a lost interval per frame, slowing the long-run rate.
B. Floating-point rounding in _accumulator -= TICK_INTERVAL produces ghost ticks; resetting eliminates them.
C. The Label is throttled by Godot's redraw policy; the value is correct in memory but displays slow.
D. The engine's frame-rate compensation reads _accumulator and skips emit calls when the value is too low (a real Godot 4.6 protection against tick-flood at high frame rates).
Reveal answer
A — discarded remainders compound into rate drift. Each frame the accumulator gains some delta; if it crosses threshold the loop emits and the accumulator should keep its sub-threshold remainder for next frame. Resetting to zero throws that remainder away. Over many frames the discarded slivers add up to a missed tick every N frames; the long-run tick rate is slightly slower than 1 / TICK_INTERVAL. The visible effect is gameplay that is correct on the first tick and progressively short by the thousandth. The fix is -= TICK_INTERVAL. B is backward: subtracting is what avoids drift; resetting causes it. C confounds display lag with arithmetic loss; the value is genuinely lower in memory. D invokes a fictitious "frame-rate compensation" hook: Godot 4.6 has no built-in tick-flood protection on _process-driven accumulators; the discarded time is simply lost.
Integration question¶
Q4 — open
In M2.3 the click handler wrote Light.value += 1.0 and the value-changed signal updated the Label. In M3.1 the tick handler writes value += light_per_second * tick_delta and the same signal updates the same Label. The two write paths are independent, both flow through the setter, and both reach the same listener. What architectural property of the M2 design does this confirm, and what would have been required if the tick path had its own tick_label to update separately?
Reveal expected answer
The setter-as-single-emit-point design from M2.2 (every write to Light.value runs the setter, the setter emits value_changed once, all listeners receive the new value) means the Label does not care who wrote, only that the value changed. Adding a passive-income source is one connection (Tick.tick.connect(_on_tick)) and one write (value += ...) on the publisher side; the consumer side is unmodified. If the tick path had its own tick_label, the design would have to either (a) duplicate the listener wiring on a separate tick_value_changed signal, (b) thread an "update reason" parameter through the signal so listeners could filter, or (c) accept that two Labels need separate updates and tolerate the divergence risk. None of those are improvements over "one signal, all listeners, every write goes through the setter." The pattern's correctness scales with the number of write sources (clicks, ticks, save-load, debug commands, hotkeys) without any change to consumers — that is the asymmetry the architecture buys.
What's next¶
Your Tick autoload is now firing every 100 ms, but light_per_second is 0.0 and the counter sits still. M3.2 introduces the discipline that lets a building's purchase update the rate — recompute-on-change. M5 is the chapter that connects real building purchases to this rate.
Glossary¶
Glossary
_process(delta)- A virtual method called once per rendered frame on every
Nodethat overrides it. Thedeltaargument is the seconds since the previous_processcall. Use for animation, input polling, and continuous per-frame work. Not suitable for fixed-cadence simulation. _physics_process(delta)- A virtual method called at a fixed rate (default 60 Hz, configurable via Project Settings → Physics → Common → Physics FPS). Intended for physics simulation;
deltais always1 / physics_fps. Could host fixed-cadence work but is awkward when the desired rate (e.g., 10 Hz idle ticks) differs from the physics rate. - accumulator
- A floating-point variable that sums elapsed
deltauntil it crosses a threshold, at which point a fixed-cadence event fires and the threshold is subtracted from the accumulator. Decouples a fixed-rate output from a variable-rate input. The standard pattern for fixed-timestep loops and deterministic simulation. - tick / tick interval
- The fixed-cadence event the
Tickautoload emits, and the constant (TICK_INTERVAL, e.g.,0.1seconds) that defines its cadence. Every per-second mechanic in the project subscribes to this event so income, animations, and offline replays all use the same time grid.