Tween Roll-up¶
What you'll learn
Tweenas Godot's animation primitive: a one-shot interpolator that drives a property from value A to value B over a duration, using an easing curve. Created vianode.create_tween(), configured withtween_propertyortween_method, runs as a child of the calling node, auto-frees on completion.tween_methodvstween_property:tween_property(target, "text", new_text, dur)sets a property directly;tween_method(callable, from, to, dur)calls a function with interpolated values. The counter roll-up usestween_methodbecause the displayed value interpolates but the text must be re-formatted (M8.1'sformat_number) at each step — the function is the format-on-tween bridge.- The kill-and-restart pattern: when a new target value arrives mid-animation, kill the in-flight tween and start a fresh one. Two competing tweens on the same property produce erratic intermediate values (each one tries to drive the property toward its own target) and visible flicker.
- The duration heuristic: 0.10–0.20 seconds per tween. Long enough to register as motion, short enough that rapid clicks don't queue up a backlog of unfinished animations and make the displayed value lag the actual value by seconds.
- Easing curves and when each fits:
EASE_OUT(fast then slow) for the counter roll-up — feels like the number is "settling" into place.EASE_IN_OUTfor slower transitions (>0.4s).EASE_INfor a takeoff feeling — usually wrong for incremental updates.
How it applies
- Animation is the difference between feeling alive and feeling broken. A counter that snaps from
1.00Kto2.00Kfeels like a glitch — players' instinctive reaction to a sudden number-change is "did I miss something?" A 150ms roll-up reads as cause-and-effect: "I clicked, so the number moved." Same data, opposite UX. Idle games run on micro-feedback cadence; M8.2 supplies the per-action heartbeat. - The animation must be cheap enough to run on every change. Resource counters update on every tick (10× per second) plus every click. A tween on each update means hundreds of tweens per minute. Godot's
Tweenis GC-light (auto-freed on done) and thetween_methodcallback is one function call per frame at the tween's update rate. Total cost for the project's UI: well under 1ms per frame. Free in performance terms; not free in correctness if mismanaged. - Kill-and-restart is the difference between smooth and broken. Skip the kill, and clicking 5 times in 200ms creates 5 tweens all targeting the Label's displayed value, each one pulling toward a different end value. The result is a chattering counter that visibly oscillates. The fix is one line —
if _tween: _tween.kill()— and forgetting it is the most common bug shipped with Tween-based counters. - Duration is a feel knob, not a perf knob. 100ms feels snappy; 300ms feels slow. Players don't articulate this preference, but they leave games that feel slow. The right value lives in the 100–200ms window for any change-driven animation; longer is reserved for rare, intentional moments (the prestige ceremony in M6.3 can afford a 600ms tween because it's a once-per-run event).
- Tweens compose with formatting. M8.1's
format_numberruns at every tween step viatween_method's callback. The displayed string changes smoothly:1.00K→1.05K→1.13K→1.23K→1.35K. Visually the digits flow rather than jumping in increments of 1.00. The smoothness comes free as long astween_methodis the bridge.
Concepts¶
Tween as a one-shot interpolator¶
A Tween is created from any Node and runs as a child of the scene tree. Each Tween runs independently, has its own duration and easing, and frees itself on completion (or when killed).
Tween
The lifecycle:
1. create_tween() returns a fresh Tween instance, registered with the node's SceneTree.
2. tween_method(callable, from, to, duration) queues an interpolation step. Multiple tween_* calls append to the tween's step queue (default sequential).
3. The tween runs on the scene's process frame: each frame, the tween advances by delta seconds; when total elapsed crosses each step's duration, the next step starts.
4. On final step completion, the tween auto-frees.
A tween is not a property of the node — it's a child node. node.create_tween() adds it to node's tree position. Killing the parent node cancels the tween implicitly.
Example
A 200-Light counter ticking up to 350 Light:
Over 0.15 seconds,_set_display is called per-frame with values rising from 200.0 to 350.0. At 60fps that's ~9 calls. Each call updates the Label's text via format_number. The visible counter walks from "200" to "350" through "212", "237", "281", "318", "342", "350".
tween_method vs tween_property¶
Two ways to drive a value:
tween_property(target, "property_path", end_value, duration) — interpolates a property directly. Right when the property is the thing you want animated and no transformation is needed. Example: fading a Label's modulate:a from 1.0 to 0.0.
tween_method(callable, from, to, duration) — calls a function with the interpolated value. Right when the displayed value is a transformation of the underlying number — like running it through format_number to produce the displayed text.
tween.tween_method(_set_display, _displayed_value, target_value, 0.15)
func _set_display(value: float) -> void:
_displayed_value = value
text = "Light: " + Format.format_number(value)
tween_method
The split is about what's animated. tween_property works when the property is the visible thing; tween_method works when there's a layer of transformation between the value and the display. Counter roll-up needs tween_method because text itself is not a smoothly-interpolatable value (it's a String, not a number) — the float is the smoothly-interpolatable thing, and format_number projects it into a String.
Example
Modulating an "Insufficient Light" tooltip's opacity:
- tween_property(tooltip, "modulate:a", 0.0, 0.4) — fade out the alpha channel directly.
Animating the same tooltip's count (e.g., shake intensity that decays):
- tween_method(_set_shake, 10.0, 0.0, 0.4) where _set_shake re-applies the value to many properties (position offset, rotation jitter).
Kill-and-restart¶
The bug to avoid: starting a new tween while a previous one is still running. Both tweens drive the same property; both want to reach different end values; per frame they both write, and the result is whichever wrote last (timing-dependent).
var _tween: Tween
func _on_value_changed(new_value: float) -> void:
if _tween and _tween.is_running():
_tween.kill()
_tween = create_tween()
_tween.tween_method(_set_display, _displayed_value, new_value, 0.15)
kill-and-restart
Tween.kill() immediately stops the tween, frees its step queue, and the engine releases its handle on the next frame. Calling kill() on a tween that has already completed is a no-op. Calling it on null raises — hence the if _tween guard.
The displayed value at kill-time is whatever the in-flight tween last wrote: somewhere between from and to. The new tween starts from that value (read as _displayed_value) and animates to the new target. Visually: a smooth transition through three target values is three back-to-back tweens, the second and third each starting where the prior killed.
Example
A player clicks Train at t=0, t=0.05, t=0.10. Each click adds 5 Light. Without kill-and-restart, three tweens overlap (durations 0.15s each). With kill-and-restart: - t=0: tween #1 starts, animating 100 → 105. - t=0.05: tween #1 is at ~103. Killed. Tween #2 starts at 103, animating to 110. - t=0.10: tween #2 is at ~106. Killed. Tween #3 starts at 106, animating to 115.
The visible counter walks 100 → 103 → 106 → 115 smoothly, no jumps. Without kill-and-restart, the counter chatters between 103 and 110 and 115 as the three tweens fight.
Duration heuristic¶
Idle-game per-update animations live in the 0.10–0.20 second window. Below 0.10s the motion is too subtle to register as animation; above 0.20s, rapid updates queue up faster than the tween completes, and the counter visibly lags the underlying number.
tween duration heuristic
Three fixed points: - 0.10s. Snappiest end of the window. Right for highest-frequency updates (every tick at 10Hz). Animation completes well before next update; no kill-and-restart contention except on rapid clicks. - 0.15s. Default for the project. Balances "feels alive" with "doesn't lag." Used for counter roll-up. - 0.20s. Slowest for per-update. Right for less-frequent UI elements (achievements, building unlocks).
Slower durations (0.4s+) are for event animations, not update animations. The prestige-ceremony fade in M6.3 can afford 0.6s because the player isn't doing anything else during it.
Example
At 0.5s tween duration, on a 10Hz tick: each new tick arrives 0.1s in. The displayed counter is at ~20% of the previous tick's animation. The next tick kills it and starts again. After 5 ticks (0.5s of game time), the displayed value is roughly where the counter was 0.4s ago — a half-second visible lag. Players feel this as "the counter is wrong."
At 0.15s tween duration: each tick's animation completes (or nearly so) before the next tick arrives. Displayed value tracks within ~0.05s of the underlying value. Players feel this as "smooth, responsive."
Easing curves¶
Tween.tween_method accepts no easing argument directly; the easing is set on the tween via set_ease(EASE_*) and set_trans(TRANS_*). For the counter roll-up:
var tween: Tween = create_tween()
tween.set_ease(Tween.EASE_OUT)
tween.set_trans(Tween.TRANS_QUAD)
tween.tween_method(_set_display, _displayed_value, new_value, 0.15)
easing curve
EASE_OUT + TRANS_QUAD is the canonical "settling" curve — fast initial movement, decelerating into the target. The number visibly "lands" at the end value rather than slamming into it. Other reasonable choices: TRANS_CUBIC (slightly more pronounced settle), TRANS_EXPO (very dramatic start, very gentle land — feels heavy).
TRANS_LINEAR (no easing) is wrong for counter roll-up: the constant rate looks mechanical, and the end of the animation has no "moment." Players don't see the number arrive; they see the animation stop.
Example
A 100-to-200 tween over 0.15s with different curves, sampled at 0.10s (66% progress):
- LINEAR: displays 166.
- EASE_OUT TRANS_QUAD: displays 188 (fast early, ~88% of the way).
- EASE_OUT TRANS_EXPO: displays 197 (very fast early, ~97% of the way).
- EASE_IN TRANS_QUAD: displays 144 (slow early, ~44% — laggy feel).
For a counter, EASE_OUT TRANS_QUAD is the sweet spot: most of the visible motion happens early (the player gets fast feedback), the final approach is gentle (the number settles).
Walkthrough¶
You'll convert the M8.1 CounterLabel from snap-update to tweened roll-up.
Step 1. Open the CounterLabel script (the one updated in M8.1 to call Format.format_number).
Step 2. Add two fields at the top of the script:
_displayed_value is the smoothly-interpolating mirror of the underlying value. The Label's text is computed from this field, not from the underlying _resources["light"].
Step 3. Replace the existing _on_resource_changed handler. The previous shape was:
func _on_resource_changed(resource_name: String, value: float) -> void:
if resource_name == "light":
text = "Light: " + Format.format_number(value)
The new shape:
func _on_resource_changed(resource_name: String, value: float) -> void:
if resource_name != "light":
return
if _tween and _tween.is_running():
_tween.kill()
_tween = create_tween()
_tween.set_ease(Tween.EASE_OUT)
_tween.set_trans(Tween.TRANS_QUAD)
_tween.tween_method(_set_display, _displayed_value, value, 0.15)
Step 4. Add the _set_display callback that the tween calls per frame:
func _set_display(value: float) -> void:
_displayed_value = value
text = "Light: " + Format.format_number(value)
The callback is two lines: store the in-flight value, format the display. Both run per frame during the tween's 0.15s.
Step 5. Save. Run the game (F5).
Step 6. Click Train. Watch the counter. Instead of snapping 0 → 5 → 10 → 15, the counter rolls smoothly. Click rapidly: the kill-and-restart pattern lets each click extend the in-flight animation to the new target without flicker.
Step 7. Buy an Initiate. Passive income kicks in. The counter now rolls up at every tick (10Hz). The animation is short enough that the next tick's animation starts where the prior one ended (or close to it) — the displayed value tracks the underlying value with no visible lag.
Step 8. Test rapid updates by spamming Train at maximum speed. The counter rises smoothly to wherever you stopped clicking. No chatter, no flicker, no double-display.
Step 9. Stress test the kill path. Add a temporary print to the kill branch:
if _tween and _tween.is_running():
print("killing tween at ", _displayed_value, " -> ", value)
_tween.kill()
Click rapidly. The output should show kill messages with monotonically rising target values, each kill leaving the displayed value somewhere between the prior tween's bounds. Remove the print before committing.
Step 10. Commit. The counter is now animated. M8.3 will style the buttons (disabled state, hover affordance) — the last surface in the polish pass.
Self-check quiz¶
Quiz
Q1. Why does the counter use tween_method rather than tween_property?
- A)
tween_propertycannot animate to a Label'stextdirectly becausetextis a String, not a number.tween_methodcalls a function that interpolates the underlying float and re-formats the string each step. - B)
tween_methodis faster thantween_property. - C) Godot deprecated
tween_propertyfor Labels in 4.6. - D)
tween_propertydoes not support easing curves.
Reveal
Correct: A. The Label's text is a String. Strings cannot be smoothly interpolated — there's no meaningful midpoint between "100" and "200". The smoothly-interpolatable value is the float. tween_method lets us interpolate the float and re-derive the String from it on each step, via format_number.
- B is wrong: performance is the same; both are per-frame callbacks under the hood.
- C is wrong:
tween_propertyis fully supported in 4.6. - D is wrong: easing applies to both via
set_ease.
Q2. A player clicks Train rapidly. Without kill-and-restart, what happens?
- A) Multiple Tweens overlap; each one writes to
_displayed_valueper frame, producing flicker as competing values fight for the field. - B) The first Tween is silently extended to the new end value.
- C) Godot raises an "already animating" error.
- D) The Label's
textbecomes"NaN".
Reveal
Correct: A. Each create_tween() returns a fresh, independent Tween. All of them run per frame. Each writes to _displayed_value and text in _set_display. The order is non-deterministic-ish (registration order in the SceneTree), so the displayed value oscillates between whatever the last-running tween wrote that frame. Visually: the counter chatters and stutters.
- B is wrong: Godot does not auto-extend; every
create_tween()is a new tween. - C is wrong: there is no error — multiple tweens are legal, just visually broken.
- D is wrong: NaN happens only if the underlying value goes NaN; tween math doesn't introduce NaN.
Q3. Why is 0.15s a reasonable tween duration but 0.50s is not, for a 10Hz tick?
- A) At 10Hz the tick fires every 0.10s. A 0.15s tween mostly completes before the next tick (a brief overlap that kill-and-restart handles cleanly). A 0.50s tween is killed every 0.10s, so the displayed value is always 0.4s behind the underlying value — a visible lag.
- B) Tweens longer than 0.20s are forbidden by Godot.
- C) 0.50s tweens consume too much CPU.
- D) Tween durations must divide evenly into the tick interval.
Reveal
Correct: A. The tween needs to complete (or nearly complete) before the next update arrives, otherwise the displayed value lags. A 0.15s animation on a 0.10s update cadence has a small overlap window; kill-and-restart handles it without visible lag. A 0.50s animation on the same cadence is killed every tick, and the displayed value is always closer to where it was 0.4s ago than to the current value.
- B is wrong: any duration is legal.
- C is wrong: CPU cost is per-frame and trivial regardless of duration.
- D is wrong: no such requirement.
Integration question¶
The CounterLabel now animates between values via Tween. The _displayed_value field is the smoothly-interpolated mirror of the underlying _resources["light"]. If the player triggers a prestige reset (M6.1's pledge_crusade) — which sets _resources["light"] = 0 instantly — what does the player see, and what should they see? Does the current implementation handle this correctly?
Reveal
Current behavior. pledge_crusade calls add_to_resource("light", -current_light) (or however the reset is implemented), which emits resource_changed("light", 0.0). The CounterLabel's handler runs: kill the in-flight tween (if any), start a new tween from _displayed_value (whatever it currently shows — possibly billions) down to 0.0 over 0.15s. The visible counter rolls down from billions to zero in 150ms.
Visible effect. The counter races down, scrolling through every magnitude on the way (2.5B → 1.7B → 800M → 350M → 100M → 30M → 5M → 1M → 200K → 50K → 8K → 1K → 200 → 30 → 0). Roughly 15 distinct displayed strings in 150ms — perceptually one continuous scroll.
Is this correct? Yes, accidentally. The fast scroll feels right for a prestige reset: the number's destruction is the moment, the scroll is the visual narrative of "everything you earned is gone." A snap-to-zero would feel less satisfying ("did the game just glitch?").
Could it be better? Yes — a longer tween (0.6s) for prestige specifically would let the player read the descent. The implementation cost is one branch in _on_resource_changed:
This is a polish-on-polish change worth deferring until a beta player flags the prestige scroll as too fast. M8.2 ships with the uniform 0.15s.
The deeper integration point. The CounterLabel doesn't know why a value changed (purchase? click? tick? prestige?). It only knows the new value. Treating all updates identically is the right default; bespoke per-cause animation is a contextual override added when (and only when) a designer says "this specific moment needs a different feel." The signal-driven decoupling from M2.3 makes this override trivial when needed.
Glossary¶
Glossary
Tween- Godot's interpolation primitive. Created via
Node.create_tween()(returns a freshTween), configured withtween_property/tween_method/tween_callback, and started implicitly. Updates per process frame, advancing each registered interpolation by the frame's delta. Auto-frees on completion unlessset_loops()is set. Distinct fromAnimationPlayer, which is for authored timeline animations;Tweenis for code-driven property interpolation. tween_method- The
Tweenstep that calls aCallablewith each interpolated value. Signature:tween_method(callable: Callable, from: float, to: float, duration: float). Per frame during the duration, the engine computes the eased value and invokes the callable. Right for counter roll-up because the displayed value is a function (format_number) of the underlying value, not the underlying value itself. Distinct fromtween_property, which sets a node property directly. - kill-and-restart
- The pattern of cancelling an in-flight tween before starting a new one. Required when value-change events can arrive faster than the tween's duration. Implementation: hold a
Tweenreference in a field, checkis_running(), callkill()beforecreate_tween(). Without this, multiple competing tweens write to the same property each frame, producing visible chatter or flicker. The "kill" frees the previous tween's step queue and stops its updates; the field is then reassigned to the new tween. - tween duration heuristic
- The 0.10–0.20 second target window for idle-game per-update animations (counter roll-up, button highlight, value pop). Short enough that 10Hz update rates (the tick) leave headroom for natural completion. Long enough to register as smooth motion rather than jump-cuts. M8.2 ships at 0.15s. Larger durations (0.4–0.8s) are reserved for once-per-run moments — prestige ceremony, level-up flash — where the player should pause and notice.
- easing curve
- The non-linear time mapping applied to a tween's progress.
set_ease(EASE_*)chooses where the curve bends (in/out/in_out);set_trans(TRANS_*)chooses the shape (linear, quad, cubic, expo, etc).EASE_OUT + TRANS_QUADis the standard for counter roll-up: fast at the start, slowing into the target. Feels like the number is settling.EASE_IN_OUTis for transitions where both takeoff and landing should feel deliberate (e.g., modal opening).EASE_INis rare in UI — feels like a hesitation before motion.