Skip to content

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 the delta parameter the engine passes in.
  • Why a 60-Hz _process callback is the wrong substrate for idle-game passive income, and what an accumulator does to fix it.
  • How to author a Tick autoload that emits a tick(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 Light to Tick.tick so the player's Light total grows by light_per_second × tick_delta on 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 — same delta every fire — produces the same Light total 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 same tick(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 _process that 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_INTERVAL is 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 to Tick.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:

  1. Multiply by delta. Light.value += rate * delta inside _process. The total Light per second is correct (rate-times-real-time) regardless of frame rate. This is almost right.
  2. 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 constantTICK_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:

  1. 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.
  2. Every consumer (achievement counter, sound-effect threshold, popup spawn) runs at frame rate too. Idle games have many such consumers; they multiply.
  3. 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_process is 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_process for 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.

  1. In the FileSystem dock, right-click scripts/New → Script. Name it tick.gd. Click Create.
  2. Replace the body. The full code-fragment ceiling for this chapter:
    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_INTERVAL
    
    Ten logical lines, one fragment, unsplittable. Note there is deliberately no class_name Tick: this script becomes the Tick autoload in step 3, and in Godot 4.6 a class_name whose name matches an autoload's name raises the error "Class 'Tick' hides an autoload singleton." The autoload registration is what makes Tick a global name reachable as Tick.tick; class_name is for scripts you reference as a type (like the BuildingData Resource in M5), not for autoloads. The constant declares the cadence; the accumulator state lives across frames; the _process body is the engine integration. Save (Ctrl+S).
  3. 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: Light and Tick. Drag Tick above Light in the list. The reason: in step 5 below, Light._ready() will subscribe to Tick.tick. For that subscription to work, Tick must already exist as a global identifier when Light._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 and Light is above Tick, fix the order now: drag in the autoload list. Close the dialog.
  4. Open scripts/light.gd. Add a new property — the per-second income rate — below the existing _value and property. The fragment:
    var light_per_second: float = 0.0
    
    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.
  5. Add a _ready() method to light.gd if one is not already there, and connect to Tick.tick:
    func _ready() -> void:
        Tick.tick.connect(_on_tick)
    
    Tick resolves to the autoload's global name; the connect is identical to M2.3's Light.value_changed.connect(...) pattern, just with a different publisher.
  6. Add the handler. The handler computes light_per_second × tick_delta and writes through value's setter — which clamps and emits value_changed, exactly as a click would:
    func _on_tick(tick_delta: float) -> void:
        value += light_per_second * tick_delta
    
    The parameter is named tick_delta to avoid shadowing the GDScript built-in delta (which is the parameter name _process uses). Same value, different name — readability over identifier collision.
  7. Save (Ctrl+S). Press F5. The counter Label still says Light: 0. Click TrainButton once: it ticks to 1. Click again: 2. Same as M2.3 — passive income is 0 so far, only clicks register.
  8. Stop the game. In light.gd, change the default of light_per_second from 0.0 to 5.0. Save. Press F5. The counter starts at 0 and ticks: 0, 0, 1, 1, 2, ... Wait — what's happening? light_per_second = 5.0 and tick_delta = 0.1, so each tick adds 0.5. The displayed integer (%d truncates the float) ticks every other tick. After ten seconds the value is 50.0; the label reads Light: 50. Click the button while it's running — clicks add 1.0 immediately, ticks add 0.5 ten times per second. The two sources interleave smoothly because both flow through the same setter and the same value_changed signal. The 5.0 test 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.
  9. Stop the game. Reset light_per_second to 0.0 and 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 of Tick._process and re-launch. The Output panel logs the accumulator value at every frame call. On a 60 Hz monitor expect delta ≈ 0.0167 per call and the accumulator ramping from 0 to 0.1 over six frames before resetting. Any sustained accumulator growth (e.g., always above 0.1) means the loop is not draining — check that the while is while, not if. 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 Node that overrides it. The delta argument is the seconds since the previous _process call. 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; delta is always 1 / 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 delta until 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 Tick autoload emits, and the constant (TICK_INTERVAL, e.g., 0.1 seconds) 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.
### Ask Claude (side-channel) Try one of these in a Claude Code session if a piece did not land: - `Walk me through one frame of _process when delta = 0.0167 and TICK_INTERVAL = 0.1 — show the accumulator's value before, the loop test, and the value after.` - `Compare _process and _physics_process for an idle game's tick source. When would I pick one over the other?` - `Re-explain the accumulator pattern using a "filling a bucket and emptying a cup at a time" analogy.`