Docs Design

Contents

Tool runs as fast as the machine allows

Status: built 2026-10-02. Read with ADR-0004 (replayable simulation), ADR-0007 (frames), m1-player.md §7–§8 (views and tours), m3-verification.md (L3) and phone-text.md §7 (L3's phone pass, which doubled L3).

A tool run (--tour, --replay, --shot) used to tick from Godot's physics loop at the game's rate, as a person's game does, so a tour of 70 game seconds took 70 seconds or more, and longer on llvmpipe, where a frame took longer than Godot's cap of 8 ticks a frame and the game fell behind the clock. L3 plays every tour that names a shot, and every texted one twice (monitor and phone), so every Director turn waited on it: Tideline's L3 player runs took 209 s, Fog Signal's 83 s (phone-text.md §7). The simulation is fixed-tick and reads no clock, so nothing in it needs the wait.

1. What a shot depends on

A shot is drawn at a tick. For a tool run to step faster than real time without changing a pixel, what a shot shows must depend on the ticks that led to it and never on how many frames were drawn between them. The views, read for anything they carry from frame to frame:

What Carried from frame to frame At a shot
kit.platformer bodies and camera eased per tick (ticked) per tick already
kit.actors each actor's memory and pose per tick (ticked) per tick already
kit.grid a slide's progress from the tick it began per tick already
kit.ui values refreshed on the first frame of a new tick read at the shot's tick
kit.ui message line aged from the first refresh that showed it a bug: see §3
lit look: camera eased per frame by the frame's own delta snapped to its goal (settle)
lit look: sky, wind, flicker from the hour and the tick per tick already
lit look: light pool a source keeps its light while it stays among the nearest see §4
kit.world_tiles chunks streamed one a frame round the player, dropped two chunks out built round the focus (settle)
kit.audio, kit.music baked and faded per frame by wall time never in a frame (and muted)
shaders none reads TIME

The player already treated --shot this way: it steps its idle ticks in a loop, calling ticked() on every view, then puts every view at the last tick (_frame_now) and captures.

2. The options

  1. Engine.time_scale and more physics steps a frame. Rejected. How many ticks a frame steps would still follow the wall clock (Godot divides the frame's scaled time by the tick length, then caps it at max_physics_steps_per_frame); every reader of a frame's delta (the camera's easing, the audio's bake budget and fades, the music's clock) would see a delta hundreds of times too big; and it is a global switch for a local need.
  2. Many ticks a frame, presenting after each. Deterministic, and closest to what a person's run does, but every tick then pays for a frame nobody sees: kit.actors poses every model, the light pool and the chunk stream run, kit.audio bakes its share of the frame budget. And the renderer takes node changes in only when a frame is drawn, so it would not give the renderer a person's history either.
  3. Headless stepping between shots (chosen). A tool run on the desktop keeps its own clock: GwPlayer._run_ahead, from _process, ticks back to back (the tour's or replay's frame, the step, ticked() on every view, the trace and the recording, exactly as tick() always did) until the run stops for a frame (a shot, a boot's shader warm-up) or ends. Nothing presents between ticks. A shot then puts every view at its tick: present(1.0), then the new GwView.settle(), where a view finishes whatever it spreads over frames (the look snaps its camera to the goal, as _frame_now did; kit.world_tiles builds every chunk round the focus instead of one a frame). _physics_process steps a person's game only.

Tests: gw_player_test.gd test_a_tool_run_keeps_its_own_clock_and_a_persons_run_the_games (with the physics loop off, a tour of a minute of game time ends within five frames, on the same state hash as the same tour stepped one tick() at a time; a person's game, and a watched tour, step not at all from frames alone; before the change the tool run never moved), and gw_world_view_test.gd test_a_shot_settles_every_chunk_round_the_focus (one chunk after a frame, all nine after settle).

Not when someone watches. A tour or replay a person watches keeps the game's rate: --pace game, which tools/play.sh passes whenever it opens a window (its desktop renderer, the default for a person), so a failing bot run is still "a replay the person can watch" (m3-verification.md decision 1). Offscreen, as every tool and the ladder run it, the pace is fast. A tour in the browser (tools/web.sh --tour, L5) keeps the game's rate too, as the page a person plays does: the page has one thread, and the browser proof is there to show a person's page plays the tour, audio and all. GwBootOptions.runs_ahead() says which. A person's run, on the desktop or the web, is unchanged.

3. What it found: a message line aged from the frame that first drew it

kit.ui's message line fades over its secs by the game's ticks (m3-verification.md decision 9b), but its birth was the first refresh that showed it, and a refresh happens on a frame. Stepping without frames, the line was first drawn at the next shot and came up bright there: in the first fast pass Tideline's poster, campfire, killed and web-beach drew the HUD line a step or two brighter (1300 pixels, up to 68 levels in a channel).

The old loop was not right either. On llvmpipe a frame of the player took longer than 8 ticks, so Godot stepped its cap of 8 ticks a frame and a frame came every 8 ticks: in Tideline's proof tour the intro line, said at tick 61 as play begins, was first drawn, and so born, at tick 68 (249 physics frames in 31 drawn). At wake (tick 91) it was then still bright, where on a machine drawing every tick it had stepped down to its ink. The frame depended on the machine.

Now GwUiView.ticked() stamps the line with the tick it came up (the first tick after it was said that the game steps to or a frame draws, so a line said at boot is stamped at the boot's frame), and the HUD label ages from the feed's tick (GwUiLabel.came_up). Test: gw_ui_view_test.gd test_a_message_line_ages_from_the_tick_it_came_up_drawn_then_or_not (red before: the line drawn only at the shot was bright, not fading).

4. What stayed

  • The light pool's slots. GwLightPool.assign keeps a source on the light it had while it stays chosen, so which OmniLight3D holds which lamp depends on the order sources were first lent, frame by frame; with frames only at shots they are lent in the order they appear there.
  • Chunks two out. kit.world_tiles keeps chunks up to two away from the player and drops the rest, so which of those outer chunks exist depends on the path between shots. They are off the frame (3x3 chunks of 32 tiles cover the 27x18-tile camera with room to spare).
  • The audio's bake. With no frames between shots kit.audio and kit.music bake only around shots. A tool run is muted (the Dummy driver); its log lines that time the bake by the clock ("asked for 7873 ms after bind") say less now, and never reach a result.

Neither of the first two changed a pixel of any gallery shot (§5). If one ever does, settle() is the place to make it the same however the frames went.

5. Proof

The frames. Every tour of the eight gallery games (examples/*/playtests/**/*.tour, 26 tours) played through tools/play.sh offscreen; the 12 that take shots played again with --phone 390x844: 38 runs, 272 PNGs (136 at 1280x720, 136 at 1920x1080), each run's result line (state hash, ticks, every files[] row with its texts, screen and subjects).

Compared PNGs the same Result lines the same
old player, real time, twice 272 of 272 37 of 38 (a save, below)
old player drawing a frame every tick (--fixed-fps 60) vs fast 272 of 272 37 of 38 (a save, below)
old player, real time, vs fast 264 of 272 38 of 38

The old player made to draw a frame every tick (Godot's --fixed-fps 60: one tick a frame, as a machine fast enough draws it) gives the fast run's frames byte for byte. Against the old player in real time on llvmpipe, 8 PNGs differ: Tideline proof's wake and Thornwick look's quest, each with its without-player frame, on the monitor and the phone. Each is the HUD's message line one step brighter in the old real-time frame (§3: born when a frame first drew it). Every result line matches, text rows and subject measurements included. The one line that differs elsewhere is Tideline's web/reload tour, which loads the save web/walk leaves in the checkout's user folder: it fails in a fresh copy that has not played walk yet, whatever the code.

L3 itself, before and after on fresh copies of the eight games: 261 PNGs each (monitor and phone frames, subject passes, the model's small copies), 253 the same; the 8 that differ are the same two shots' message lines. Every rung's report line is the same.

The times. verify --level L3 on a fresh copy, L0 to L3, inside one tools/heavy.sh slot with its jobs run one by one (GAMEWRIGHT_HEAVY empty), before and after back to back on a box busy with other sessions:

Game Before After
Tideline 311.4 s 139.8 s
Thornwick 319.1 s 163.9 s
Nightcoast 166.4 s 142.9 s
Fog Signal 93.1 s 30.8 s
Burrow Run 62.9 s 33.5 s
Lamplighter 61.3 s 60.7 s
Acorn Rooftops 40.7 s 38.7 s
Pier Pressure 25.9 s 22.1 s
all eight 1081 s 632 s

Games whose L3 tour is short (Lamplighter, Acorn Rooftops, Pier Pressure) spend their time in L1, L2 and the player's boot; Nightcoast's L2 bots are most of its time.

The 38 tour runs above, each through tools/play.sh, took 1267 s and 1667 s in the old player's two passes and 671 s in the fast one over the same busy hour (340 s again later, on a quieter box). By tour, old then fast: Tideline proof (4591 ticks) 121 s, 78 s; Thornwick look (4964) 144 s, 83 s; Thornwick proof (4482, no shots) 151 s, 35 s; Fog Signal look (1375) 30 s, 7.5 s; Burrow Run look (1014) 24 s, 11 s; Acorn Rooftops' web proof (2113, no shots) 40 s, 6.9 s. A tour of no shots is now mostly its boot.

The web proof's desktop comparison (tools/web.sh: engine-cli sim --tour) never used the player: it was already headless, as fast as the CPU allows, and is unchanged (Acorn Rooftops' proof tour 2.7 s before, 2.6 s after; the same state hashes). tools/web.sh (the toy, a person's visit, the proof tour and the reload) passes 27 of 27 checks and ends on the desktop's state.

Where a fast run's time goes now. Tideline's proof tour alone, 55 s: about 18 s of boots (the bundle and world, and a shader warm-up of 1.2 to 2.2 s for each of its four boots), about 25 s for 13 shots of 40 PNGs on llvmpipe (0.7 s a frame at 1280x720, and the subject passes), and about 13 s for its 4591 ticks, half of that in 250 ticks of goto prop: (below).

6. Not yet

  • goto prop: costs 25 ms a tick in Tideline: GwGotoDriver._target looks through every prop within 512 m for the nearest that isn't worked out, twice a tick. Remembering the target would change which prop a walk ends at, so it needs its own design and its own proof.
  • A warm-up for every boot. A tool run waits for the shader warm-up (one program a frame) after each boot, load or quit to the title; nothing in a shot needs it, since the shot's own draw would compile what it uses. Skipping it in tool runs would save 1 to 2 s a boot.
  • heavy.sh's gate. Run as the core runs it, each L3 player run waits for its own slot (three readings 10 s apart): about 20 s a run on a quiet box, more on a busy one, now often more than the run itself.
  • The browser. A tour there still plays at the game's rate (§2).

From docs/design/fast-runs.md in the Gamewright repository.