Skip to content
ct

Calendar Time v2.0

Clock-Driven Animation Pattern

This guide is the worked code example for converting Timer-based animations (or "raw speed" animations) to clock-driven animations with a real-time-friendly inspector knob. It is the answer to the que

Status
Current
Version
v2.0
Updated
Current with plugin v2.0

This guide is the worked code example for converting Timer-based animations (or "raw speed" animations) to clock-driven animations with a real-time-friendly inspector knob. It is the answer to the question: "how do I make my ambient animation pause with the world?"

The full speed-stack math (engine → host → clock → animation) is in speed-stack-and-clock-pacing.md. This guide focuses on the pattern as it applies to a single animation node.

When to use this pattern

The pattern applies to any per-frame visual change that:

  • should pause when the player pauses the world (clock.speed_multiplier = 0)
  • should speed up with the slider (e.g. "fast forward" gameplay)
  • should NOT pause when the player pauses Godot itself (cosmetic animations like cursor blink can use raw Engine.time_scale for that)

Examples in the demo:

  • Ninja idle flip (demo/flipper.gd): flips flip_h every interval_real_seconds. Used as a dressing animation but exposed to the clock so the whole cast shares pause semantics.
  • Mood bubble bob (demo/objects/emotes/animated_emote_2d.gd): bobs position.y in a sin wave with bob_period_real_seconds period and amplitude pixel amplitude.

Both follow the same pattern. Both are documented in code with "worked example of the clock-driven animation pattern" comments.

When NOT to use this pattern

The "pin to clock or pin to engine" table in speed-stack-and-clock-pacing.md is the definitive list. Quick summary:

  • Use clock time for: NPC movement, day/night lighting, emote bobbing (it's a world actor), and any animation where pausing the game should freeze it.
  • Use raw engine time for: cursor blink, button hover, menu open/close, audio, and any animation that should keep playing while the world is paused. Use manual microsecond driving for save-load replay.

If you can't tell which one applies, ask: "should this animation stop when the player presses the pause button?" If yes → clock time. If no → engine time.

The pattern, in three steps

Step 1 — declare a real-time-friendly export

The user-facing knob should be in the units the designer actually thinks in. For periodic events that's "seconds between occurrences". For sinusoidal motion that's "period in seconds" or "frequency in Hz". Both are wall-clock real-time seconds.

# Ninja flipper — every N real-seconds, flip the sprite.
@export var interval_real_seconds :float = 10.0

# Mood bubble — one bob cycle every N real-seconds.
@export var bob_period_real_seconds :float = 1.0

Avoid "radians per game-second" or "speed multiplier" as user-facing fields. Those are implementation details. See speed-stack-and-clock-pacing.md for why.

Step 2 — derive a game-time interval internally

The animation has to accumulate game-time (because it's listening to game_seconds_advanced), not real-time. So the real-time knob is converted to a game-time interval by accounting for the speed chain.

There are two sub-patterns depending on whether the animation wants real-time pacing independent of clock speed, or a derived speed that compensates for both multipliers.

Sub-pattern A — flipper (real-time pacing, excludes smult)

Use when the real-time knob IS the source of truth and you want exactly one event every N real-seconds regardless of clock speed. The host's game-seconds delta already includes smult, so the flipper only needs dmult to convert real-seconds to game-seconds:

var _interval_game_seconds :float = 0.0
var _elapsed_game_seconds :float = 0.0

func _recompute_interval_game_seconds()-> void:
    var dmult:= time_scale.delta_multiplierif time_scale!= null else 1.0
    var old_interval:= _interval_game_seconds
    _interval_game_seconds= maxf(interval_real_seconds* dmult,0.001)
    if old_interval!= _interval_game_seconds:
        _elapsed_game_seconds= 0.0

smult is intentionally excluded: the host already scales the game-seconds delta it emits via advance_scaled_microseconds, so including smult here would make the flipper speed up at higher slider values — the opposite of the user-facing "10 real-seconds per flip" contract.

Sub-pattern B — emote (derived speed, divides by dmult * smult)

Use when the animation's speed field is derived from the period and needs to compensate for both multipliers so the visual result is real-time. The emote accumulates game-seconds via time += p_seconds, so its speed (radians per game-second) must slow down when the game runs faster:

func _recompute_speed()-> void:
    var dmult:= time_scale.delta_multiplierif time_scale!= null else 1.0
    var smult:= clock.speed_multiplierif clock!= null else 1.0
    if bob_period_real_seconds<= 0.0:
        speed= 0.0
        return
    var radians_per_real_sec :float = TAU / bob_period_real_seconds
    speed= radians_per_real_sec/ maxf(dmult* smult,0.0001)

The maxf(_, 0.0001) guard avoids divide-by-zero if someone sets time_scale.delta_multiplier = 0 (paused host) AND clock.speed_multiplier = 0.

Step 3 — listen to clock signals and recompute on change

The animation subscribes to two signals:

  1. game_seconds_advanced(amount, total) — fires every time the clock advances; this is how the animation actually progresses.
  2. clock_speed_changed(new_speed) — fires when clock.speed_multiplier changes; this triggers a re-derivation of the internal game-time interval so the user-facing real-time knob keeps its meaning at any clock speed.
@export var clock :GameClock:
    set(value):
        if clock!= null:
            if clock.signal_bus.game_seconds_advanced.is_connected(_on_game_seconds_advanced):
                clock.signal_bus.game_seconds_advanced.disconnect(_on_game_seconds_advanced)
            if clock.signal_bus.clock_speed_changed.is_connected(_on_clock_speed_changed):
                clock.signal_bus.clock_speed_changed.disconnect(_on_clock_speed_changed)
        clock= value
        if clock!= null:
            if not clock.signal_bus.game_seconds_advanced.is_connected(_on_game_seconds_advanced):
                clock.signal_bus.game_seconds_advanced.connect(_on_game_seconds_advanced)
            if not clock.signal_bus.clock_speed_changed.is_connected(_on_clock_speed_changed):
                clock.signal_bus.clock_speed_changed.connect(_on_clock_speed_changed)
        _recompute_speed()

@export var time_scale :TimeScale:
    set(value):
        time_scale= value
        _recompute_speed()

time_scale is the TimeHost.time_scale resource the animation should behave against. Designers wire it in the scene like any other Resource export. If the demo doesn't bind it, time_scale = null falls back to delta_multiplier = 1.0 (the "raw real-time" calibration).

Step 4 — the accumulator resets on interval change

When the user changes interval_real_seconds (or bob_period_real_seconds), or when the clock speed changes, the existing accumulator might already exceed the new interval. The cleanest semantic is: when the interval changes, the new interval starts fresh from this moment.

func _recompute_interval_game_seconds()-> void:
    var dmult:= time_scale.delta_multiplierif time_scale!= null else 1.0
    var smult:= clock.speed_multiplierif clock!= null else 1.0
    var old_interval:= _interval_game_seconds
    _interval_game_seconds= interval_real_seconds* dmult* smult
    # Reset on interval change so the new interval starts fresh.
    if old_interval!= _interval_game_seconds:
        _elapsed_game_seconds= 0.0

Worked example: ninja flipper (demo/flipper.gd)

@export var interval_real_seconds :float = 10.0

func _on_game_seconds_advanced(p_amount :float, _p_total :float)-> void:
    if p_amount<= 0.0:
        return
    _elapsed_game_seconds+= p_amount
    while _elapsed_game_seconds>= _interval_game_seconds:
        _elapsed_game_seconds-= _interval_game_seconds
        _flip()

func _flip()-> void:
    flip_h= not flip_h

At demo defaults (delta_multiplier = 1000, interval_real_seconds = 10):

  • _interval_game_seconds = 10 × 1000 = 10_000 (no smult)
  • One flip per 10,000 game-seconds
  • Per real-second: 1000 game-seconds (smult is already in the delta)
  • Time to flip: 10,000 / 1000 = 10 real-seconds ✓ (matches the user-facing knob, independent of clock speed)

Worked example: mood bubble bob (demo/objects/emotes/animated_emote_2d.gd)

@export var amplitude :float = 5.0
@export var bob_period_real_seconds :float = 1.0

func _on_game_seconds_advanced(p_amount :float, _p_total :float)-> void:
    if p_amount<= 0.0:
        return
    _advance(p_amount)

func _advance(p_seconds :float)-> void:
    time+= p_seconds
    var animation_offset:= time* speed
    var sin_position:= amplitude* sin(animation_offset)
    position.y= _baseline_y+ sin_position

The speed field is derived from bob_period_real_seconds:

func _recompute_speed()-> void:
    var dmult:= time_scale.delta_multiplierif time_scale!= null else 1.0
    var smult:= clock.speed_multiplierif clock!= null else 1.0
    if bob_period_real_seconds<= 0.0:
        speed= 0.0
        return
    var radians_per_real_sec :float = TAU / bob_period_real_seconds
    speed= radians_per_real_sec/ maxf(dmult* smult,0.0001)

At demo defaults: speed = (2π/1) / (1000 × 3) = 0.00209 rad/game-sec. After 1 real-second (= 3000 game-sec), the bob has advanced 0.00209 × 3000 = 6.28 rad = 1 cycle. One bob per real-second ✓.

Note the _baseline_y capture: the bobbing is additive around the scene-authored position.y, not absolute. Without this, a bubble spawned at y=-13 would snap to y=0 on the first frame. The _baseline_y = position.y line in _ready() captures the authored y, and _advance adds sin_position to it. This is a separate fix from the clock-driven conversion (it's a spawn-snap fix) but lives in the same file because they share the same code path.

Debugging checklist

If your animation isn't behaving as expected:

  1. Print the interval: in _on_game_seconds_advanced, log the current _interval_game_seconds and _elapsed_game_seconds. They should converge to interval_real_seconds × dmult (flipper) or TAU / (dmult × smult) (emote speed) and grow at the game-seconds per real-second rate.
  2. Verify time_scale is bound: a null time_scale falls back to delta_multiplier = 1.0, which is fine for tests but rarely what you want in production.
  3. Check clock is set: a null clock means the animation isn't listening to any clock. The animation won't advance.
  4. Math check: at default demo settings with a 10-real-second knob, the animation should fire every 10 real-seconds at any speed. If it fires more often, your delta_multiplier × speed_multiplier is producing fewer game-seconds per interval than you expect. See speed-stack-and-clock-pacing.md for the math.
  5. Reset on change: if changing clock.speed_multiplier mid-test leaves the animation with a leftover accumulator from the old interval, reset _elapsed_game_seconds = 0 in _recompute_interval_game_seconds() when the interval changes.

See also