Skip to content

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.

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.

  • render/render_clock.gd: RenderClock.FX_REAL_DT (bool, default false) and RenderClock.fx_dt(delta, death_ts) = (minf(delta, 0.1) if FX_REAL_DT else 1.0 / 60.0) * death_ts.
  • main.gd hands fx_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.gd as {"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.

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).

  1. 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: the dark-cosmos-console MCP send_command with cmd = {"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.
  2. Do it on a 120 Hz iPhone and on the Apple TV, in a fight with beams, a Nova, a Blade and a Boss.
  3. Undo by flipping the row back (null removes the override). "reset": true also 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.

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_dt ignores delta when OFF (exactly 1/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 through Tunables.apply AFTER _on_mode_chosen (that call ends in Tunables.apply, which resets every registered static, so a flag set first silently measures OFF), spawns a weapon-less beam event (0.12 s life, legacy pool; an event with a wid would go to a v2 look instead) and drives main._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 literal 1/60 (any spacing: 1.0 / 60.0, 1.0/60.0, 1 / 60) in the arguments of an .advance( call in main.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: why on a line of the call. Exempt: render/prewarm_pass.gd (scripted warm-up steps, not playback) and fx/weapons/candidates/ (comparison archive). tools/ and tests/ are not scanned: offline harnesses such as tools/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.

  • That _process really 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.