FX cadence: effects age by frame count, not time
FX cadence: effects age by frame count, not time
Section titled “FX cadence: effects age by frame count, not time”RenderClock.FX_REAL_DT (local flag, default OFF) puts every FxManager look on real frame time.
OFF hands FxManager exactly the value it got before, so the shipped look is unchanged. Nothing changes
for a player until Chris flips it.
The bug
Section titled “The bug”main._process runs once per RENDERED frame and called fx_layer.advance(1.0 / 60.0 * death_ts) every
time. So every ring, Plasma Nova, beam, railgun tell, bolt, slash, reaction label and weapon-FX-v2 event
look (fx/fx_manager.gd advance, including _v2_layers) ages display rate / 60 times too fast:
| Display | Effects play at | Seen on |
|---|---|---|
| 120 Hz | 2.0x | ProMotion iPhones (iPhone16,x) and iPads (iPad13,8, iPad8,6), a ProMotion Mac window (about 115 fps average in telemetry) |
| 60 Hz | 1.0x | Apple TV since b234, an older 60 Hz Mac |
| 40-55 fps | 0.67x to 0.92x (0.83x at 50 fps) | Apple TV in a heavy fight (builds 320+ spend 10.6% of samples there) |
(Device and fps figures are from the telemetry reads made when this was scoped; they were not re-measured in this change.)
Its siblings already age by real time. death_dissolve and warden_fx get minf(delta, 0.1) * death_ts,
screen_fx and death_sequence get minf(delta, 0.1), and screen_punch, shockwave_layer and
spacetime_warp get delta. The FxManager looks were the odd ones out.
docs/research/gfx-loop-2026-10-07.md noted it (“so it was flagged, not fixed”) and the Scatter entry in
docs/architecture/rendering-and-performance.md caveats it (the tracers match the pellets only at 60 fps).
Corollary: a ProMotion Mac window plays effects at twice the Apple TV’s speed, so FX judged by eye on it were judged at 2x. Looks approved on a 120 Hz Mac or iPhone were approved at 2x speed, so the later default flip will make them look about half as fast there.
The flag
Section titled “The flag”render/render_clock.gd:RenderClock.FX_REAL_DT(bool, defaultfalse) andRenderClock.fx_dt(delta, death_ts)=(minf(delta, 0.1) if FX_REAL_DT else 1.0 / 60.0) * death_ts.main.gdhandsfx_layer.advance(RenderClock.fx_dt(delta, death_ts)), once per rendered frame while a run exists, above the game-over, area-warp, level-up and Dimension-choice early returns, so effects keep ageing on those screens (not on the start menu, where there is no run).- Registered in
net/tunables_registry.gdas{"type": "bool", "local": true}: per device, never part of the join config hash, never LAN-synced. Docs:tools/tunable_docs/docs.d/render_clock.json, group Visuals & FX, label “FX ageing: real frame time (A/B)”. - Read every frame, so a flip lands on the next rendered frame (pinned by
test_the_flag_is_read_every_frame_so_a_live_flip_lands_at_once). - With the flag ON a stall (a frame longer than 0.1 s) ages effects 0.1 s, never more, like the siblings.
How Chris A/Bs it
Section titled “How Chris A/Bs it”Needs a Dev build that contains the key, and the console Worker deployed with the regenerated
control/src/tunable_docs.js (otherwise the row sits under Other with its raw key).
- Remote Console, Tunables tab, target the device, group Visuals & FX, row
RenderClock.FX_REAL_DT: set it to true mid-run, set it back to compare. Scriptable route: thedark-cosmos-consoleMCPsend_commandwithcmd={"kind": "set_tunables", "tunables": {"RenderClock.FX_REAL_DT": true}}. The live layer is Dev-only and held in memory, so an app restart is back to OFF. - Do it on a 120 Hz iPhone and on the Apple TV, in a fight with beams, a Nova, a Blade and a Boss.
- Undo by flipping the row back (
nullremoves the override)."reset": truealso works but clears EVERY live override on that device, not just this one.
What to look for. ON is the designed 1.0x. Expect:
- 120 Hz devices (ProMotion iPhones and iPads, a ProMotion Mac): effects look about HALF as fast as now. Rings and flashes linger twice as long, labels hang twice as long, and the Scatter tracers, which are timed to ride the real pellets, stop running ahead of them. If a look now reads sluggish there, it was tuned at 2x speed and needs retuning, not the flag reverting.
- Apple TV in a heavy fight: effects look faster, about 20% at 50 fps (8% at 55 fps, 50% at 40 fps), tighter on the sim. Nothing changes at a steady 60 fps.
- Mac look reviews: flip the flag ON before judging a look by eye, so you see what the 60 Hz devices show.
Making ON the default is a separate decision (a look call, Chris’s): flip the bundled default in
render/render_clock.gd and test_the_flag_is_a_local_bool_tunable_defaulting_off, or publish the key in
a remote-config patch (a look patch, which needs Chris’s authorization to publish). The rest of
tests/test_render_cadence.gd sets the flag explicitly, so it keeps passing either way. On the flip, the
Scatter caveat in docs/architecture/rendering-and-performance.md and the “flagged, not fixed” note in
the gfx-loop research are obsolete.
The conformance test and the lint
Section titled “The conformance test and the lint”tests/test_render_cadence.gd, in the gate. Run alone (prints one FX_CADENCE_JSON line per table):
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_render_cadence.gd -gexit | grep FX_CADENCE_JSON-
Unit:
fx_dtignoresdeltawhen OFF (exactly1/60 * ts), follows it with the 0.1 clamp when ON, and the speed table above (rate * fx_dt(1/rate): 2.0 / 1.5 / 1.0 / 0.83 OFF at 120 / 90 / 60 / 50 Hz, 1.0 everywhere ON). -
Real main: boots
main.tscn, starts Survival, sets the flag throughTunables.applyAFTER_on_mode_chosen(that call ends inTunables.apply, which resets every registered static, so a flag set first silently measures OFF), spawns a weapon-lessbeamevent (0.12 s life, legacy pool; an event with awidwould go to a v2 look instead) and drivesmain._process(1/hz)at 50, 60, 90 and 120 Hz until the pool takes it back. Also with_paused_for_levelup. Measured:Display OFF frames / wall ON frames / wall 50 Hz 8 / 0.160 s 6 / 0.120 s 60 Hz 8 / 0.133 s 8 / 0.133 s 90 Hz 8 / 0.089 s 11 / 0.122 s 120 Hz 8 / 0.067 s 15 / 0.125 s OFF: the frame count never depends on the display rate, so the wall time halves at 120 Hz. ON: every rate lands within one displayed frame of the look’s own 0.12 s (asserted as 0.12 s <= wall <= 0.12 s + one frame, and every rate within one 50 Hz frame of every other). The numbers are identical with the level-up pause up.
-
Lint (
test_no_look_is_aged_by_a_fixed_frame_step): no literal1/60(any spacing:1.0 / 60.0,1.0/60.0,1 / 60) in the arguments of an.advance(call inmain.gd,render/**,fx/**,ui/**. Comments are stripped first, so a note saying “not 1/60 per frame” is fine, and calls wrapped over several lines are seen. A deliberate fixed step carries# fixed-step: whyon a line of the call. Exempt:render/prewarm_pass.gd(scripted warm-up steps, not playback) andfx/weapons/candidates/(comparison archive).tools/andtests/are not scanned: offline harnesses such astools/weapon_vfx/*step looks by a fixed 1/60 on purpose. The lint cannot see a step hidden behind a variable; the real-main test is what catches that.
What only a device can verify
Section titled “What only a device can verify”- That
_processreally runs at the rate the table assumes on a ProMotion iPhone and iPad (adaptive refresh can sit below 120 Hz; ON is right at any rate). - That the Apple TV holds 60 Hz outside heavy fights, so OFF vs ON is invisible there in calm play.
- That the Scatter tracers line up with their pellets with the flag ON on a 120 Hz device and below 60 fps (inferred from the Scatter note, not measured here).
- The look call itself: whether effects at the designed 1.0x read as intended on a 120 Hz screen.