Version 2.7 · 2026-10-10 · GDScript · v2 (stages, bosses) is specified in §11 and overrides §2–§8 where they conflict
Engine version policy. The project pins Godot 4.7.1 (GDD). The editor installed on the build
machine during this cycle is 4.7.0 stable (4.7.stable.official.5b4e0cb0f), so every verification
result recorded in the runner docs was produced on 4.7.0 unless it says otherwise. Both are supported
for development; any behavioural difference found between 4.7.0 and 4.7.1 must be recorded in §10, and
final acceptance of the runner re-runs the §9 commands on 4.7.1.
This document is the single source of truth shared by the two agents building the Endless Runner ("The Codebase Corridor"):
- Backend (Claude) owns gameplay logic, project configuration, autoload additions, graybox fallbacks, tests, and this contract.
- Frontend (Codex) owns everything the player sees and hears: Hoppy's model and animation, hazard, power-up and pursuer visuals, the corridor environment, HUD, runner menu, game over, pause, audio, shaders.
Neither side edits the other's files. Changes to this contract are made by the backend and announced in the PR description. Design authority: the GDD Endless Runner section and the internal technical implementation baseline §9.
| Owner | Paths (relative to hoppys-adventure-arcade/ unless noted) |
|---|---|
| Backend | project.godot, app/*, scenes/runner/runner_game.{tscn,gd}, scenes/runner/runner_layers.gd, scenes/runner/systems/*, scenes/runner/player/runner_player.{tscn,gd}, scenes/runner/player/runner_visual_base.gd, scenes/runner/hazards/hazard.{tscn,gd}, scenes/runner/hazards/hazard_defs.gd, scenes/runner/hazards/hazard_visual_base.gd, scenes/runner/powerups/power_up.{tscn,gd}, scenes/runner/powerups/power_up_defs.gd, scenes/runner/powerups/power_up_visual_base.gd, scenes/runner/pursuer/pursuer.gd, scenes/runner/pursuer/pursuer_visual_base.gd, scenes/runner/chunks/*, scenes/runner/data/*, tests/* except tests/runner_visual_smoke.gd, repo docs/endless-runner-contract.md, docs/endless-runner-systems.md, scenes/runner/boss/boss_arena.tscn, scenes/runner/boss/boss_encounter.gd, scenes/runner/boss/boss_defs.gd, scenes/runner/boss/boss_visual_base.gd, scenes/runner/hazards/projectile.{tscn,gd}, scenes/runner/powerups/tool_pickup.{tscn,gd}, scenes/runner/powerups/tool_defs.gd (v2) |
| Frontend | scenes/runner/runner_menu.{tscn,gd}, scenes/runner/environment/**, scenes/runner/ui/**, scenes/runner/audio/**, scenes/runner/shaders/**, scenes/runner/player/hoppy_runner_visual.{tscn,gd} and scenes/runner/player/dev/**, scenes/runner/hazards/visuals/**, scenes/runner/powerups/visuals/**, scenes/runner/pursuer/pursuer_visual.{tscn,gd}, Assets/runner/** (with Assets/runner/LICENSES.md), tests/runner_visual_smoke.gd, repo docs/endless-runner.md, docs/previews/endless-runner-*.png, scenes/runner/boss/visuals/**, scenes/runner/boss/boss_fx.{tscn,gd}, scenes/runner/boss/boss_art.gd, scenes/runner/boss/tool_art.gd, scenes/runner/boss/telegraph.*, scenes/runner/boss/tool_tracer.gd, scenes/runner/boss/damage_number.gd, scenes/runner/boss/dev/** (v2) |
Rules:
- The backend ships graybox placeholders at four frontend paths in the contract PR
(
runner_menu.tscn,ui/runner_hud.tscn,ui/pause_menu.tscn,ui/game_over.tscn) so the game is playable with no art. The frontend may overwrite or delete them freely.ui/runner_hud,ui/pause_menuandui/game_overare frozen from the end of that PR: the backend never edits them again.scenes/runner/runner_menu.*has exactly one further backend edit, in PR 2, bringing the graybox up to the §2 four-button set sotests/runner_scaffold_check.gdcan assert the same structure (RunnerMenuroot,buttons.size() == 4) against either scene. Once the frontend's F6runner_menulands it is exclusively frontend-owned and the backend never edits it again; any later backend scaffold need goes indocs/endless-runner.mdunder "Requests to backend" instead. - Every other frontend scene is optional at runtime. Backend code mounts a frontend scene only if
ResourceLoader.exists(path); otherwise it builds a primitive graybox in code. A missing or broken frontend file must never stoprunner_game.tscnfrom loading. - All backend → frontend calls are guarded with
has_method. The frontend never needs to implement a hook it does not use. - Code style for both sides (matches the repo):
snake_casefiles, noclass_name,const X = preload("res://..."), typed GDScript with:=, tabs, an explicit return type on every function (-> voidfor procedures), UI built imperatively in_ready()usingscenes/main_menu/menu_style.gd(Style.theme(),Style.label(),Style.button()). Commit generated.gd.uidfiles. Never mutate shared.tresresources at runtime.
hub ──AppRouter.go("runner")──▶ runner_menu.tscn (RunnerMenu)
▲ │ Play → AppRouter.go("runner_game")
│ Return / ui_cancel ▼
└──────────────────── runner_game.tscn (RunnerGame) ──▶ GameOver overlay (inside runner_game)
▲ │ Retry → game.retry() (AppRouter.go("runner_game"))
└───────────────────────────────┘ Menu → game.go_to_menu() (AppRouter.go("runner"))
AppRouter.ROUTESalready maps"runner"tores://scenes/runner/runner_menu.tscn; it gains"runner_game": "res://scenes/runner/runner_game.tscn".- Inside
runner_game.tscn(pause menu, game over), frontend buttons callgame.retry()/game.go_to_menu()rather thanAppRouterdirectly, so the tree is never left paused. The runner menu scene, which has no game instance, callsAppRouter.go(...)/AppRouter.quit_game()itself. - The runner menu offers exactly four buttons, in this order:
01 Play→AppRouter.go("runner_game"),02 Leaderboard→ toggles a panel showing the top 10 read with the staticrunner_leaderboard.load_entries()(no game instance needed; short boards pad with---),03 Return to Main Menu→AppRouter.go("hub"),04 Quit Game→AppRouter.quit_game(). It keeps the hub's return contract, andui_cancelcloses the leaderboard panel first, else → hub. - Music: the runner menu and
runner_audiocallAudioService.play_music(stream, -12.0); the call is a no-op if that stream is already playing, so menu → game does not restart the track.AppRouterrestores the hub track when routing tohub.
RunnerGame (Node3D, runner_game.gd, process_mode ALWAYS) backend
├─ Systems (Node, PAUSABLE)
│ ├─ SpeedController ├─ HitManager ├─ ScoreManager ├─ EffectManager
│ ├─ Spawner └─ Autopilot (disabled unless tests / F7)
├─ World (Node3D, PAUSABLE)
│ ├─ Floor (StaticBody3D, BoxShape3D 12×1×400 with top at y = 0, layer World) backend, invisible when a corridor is mounted
│ ├─ Chunks (Node3D) spawner parents live chunks here
│ ├─ GrayboxEnvironment (Node3D) backend lighting + grid; freed when a corridor mounts
│ └─ Environment (Node3D) MOUNT: environment/corridor.tscn
├─ Player (runner_player.tscn root CharacterBody3D, PAUSABLE) backend
│ ├─ BodyShape (CapsuleShape3D r 0.4, h 1.8, centre y 0.9)
│ ├─ Hurtbox (Area3D, layer PlayerHurtbox, monitorable only)
│ │ ├─ StandShape (CapsuleShape3D r 0.4 h 1.8) └─ RollShape (CapsuleShape3D r 0.4 h 0.9, disabled)
│ └─ Visual (Node3D) MOUNT: player/hoppy_runner_visual.tscn
├─ Pursuer (Node3D, pursuer.gd) backend
│ └─ Visual (Node3D) MOUNT: pursuer/pursuer_visual.tscn
├─ CameraRig (Node3D, camera_rig.gd) └─ Camera (Camera3D) backend, exports tunable by frontend
├─ HUD (CanvasLayer 10, PAUSABLE) MOUNT: ui/runner_hud.tscn
├─ Overlays (CanvasLayer 20, ALWAYS) MOUNT: ui/pause_menu.tscn, ui/game_over.tscn
├─ Audio (Node, ALWAYS) MOUNT: audio/runner_audio.tscn
└─ DebugOverlay (CanvasLayer 90, ALWAYS, debug_overlay.gd) backend; F3 toggles
Mounting: for each MOUNT path, the backend calls ResourceLoader.exists(path), then
load(path) as PackedScene; only a non-null PackedScene is instantiated and added under the mount node
(a missing, failed or wrongly typed resource is logged with push_warning and the code-built graybox is
used instead). If the instance has bind, bind(self) is called before run_started is emitted.
Hazard, power-up, pursuer and player roots mount their own Visual with the same validation
(<kind>_visual.tscn if valid, else a code-built primitive).
Physics: 60 Hz, Jolt, physics/common/physics_interpolation = true. World forward is −Z. The player
never moves in Z (axis_lock_linear_z); chunks move toward +Z at the authoritative speed.
| Signal | When |
|---|---|
run_started(seed: int) |
After every system is reset and the first chunks are placed |
state_changed(state: StringName) |
ready → running → caught → finished |
health_changed(hp: int, max_hp: int) |
Initial emission after run_started, then on every change |
damage_taken(kind: StringName, hp: int) |
An unblocked hit landed (kind = hazard kind) |
damage_blocked(kind: StringName, reason: StringName) |
reason ∈ iframes, shield, lgtm, nitro, super_jump |
shield_changed(active: bool) |
Approve Shield stored / consumed |
pursuit_started(recovery_seconds: float) |
First hit while SAFE |
pursuit_progress(remaining: float, total: float) |
Every physics tick while PURSUED |
pursuit_recovered(reason: StringName) |
timer (clean 18 s) or shield (shield absorbed a hit while pursued) |
caught() |
Second hit while PURSUED; the catch cinematic starts (world decelerates, controls lock) |
run_finished(result: Dictionary) |
caught_sequence_seconds (3.0 s) after caught; leaderboard already written. Show Game Over here. Emitted exactly once per run |
leaderboard_saved(entries: Array, rank: int) |
Just before run_finished, and again after a successful submit_name |
score_changed(score: int, multiplier: float) |
Only when the integer score or the multiplier changes |
bonus_awarded(points: int, reason: StringName) |
Pickup or blast bonus |
speed_changed(speed: float, normalized: float) |
Every physics tick; normalized = (speed − base_speed) / (max_speed − base_speed), may exceed 1.0 during Nitro |
difficulty_changed(difficulty: float) |
When difficulty moves by ≥ 0.01, plus the terminal 1.0 — that last step is always announced even though it is inside the 0.01 window, and 1.0 is never emitted twice |
power_up_collected(kind: StringName, duration: float) |
duration 0 for one-shot kinds |
power_up_expired(kind: StringName) |
Timed effect ended, Code Review Bonus lost, or Super Jump consumed |
hazard_cleared(hazard: Area3D) |
Auto-Fix removed one hazard (its visual already received on_cleared()) |
hazards_blasted(count: int) |
Once per Auto-Fix, after all hazard_cleared |
player_action(action: StringName) |
jump, super_jump, land, roll, roll_end, lane_left, lane_right, lane_blocked, hurt, dead |
chunk_spawned(chunk: Node3D) |
Debug / cosmetic use |
paused_changed(paused: bool) |
Pause menu shows on true |
config (RunConfig), state: StringName, paused: bool, seed: int, run_id: String,
elapsed: float (unpaused running seconds), distance: float (metres), speed: float,
normalized_speed: float, difficulty: float (0–1), hp: int, score: int, multiplier: float,
player: CharacterBody3D, camera_rig: Node3D, cosmetic_rng: RandomNumberGenerator (seeded per run;
frontend may use it for deterministic cosmetics).
| Method | Behaviour |
|---|---|
pause() / resume() / toggle_pause() |
Only while running. resume() re-syncs held input so nothing fires on unpause |
retry() |
Unpauses and routes to runner_game (fresh session) |
retry_boss() |
v2.5 (§11.13): unpauses and routes to runner_game, which opens in front of the boss the run died at. Falls back to retry() when boss_checkpoint is empty |
go_to_menu() |
Unpauses and routes to runner |
hit_stop(seconds: float) |
Brief Engine.time_scale dip owned by the backend (max 0.12 s) |
submit_name(name: String) -> bool |
Sanitises (trim, strip control chars, ≤ 16, blank → Hoppy) and renames this run's leaderboard entry. Returns true if persisted. Never adds a second entry |
get_leaderboard() -> Array[Dictionary] |
Current top 10 |
get_result() -> Dictionary |
Empty until finished |
get_active_effects() -> Dictionary |
kind → {remaining, total} snapshot for HUD polling |
camera_rig.shake(strength: float, duration := 0.35) / camera_rig.punch_fov(degrees: float, seconds: float) |
Camera juice |
start_run(seed := 0) (backend-initiated; the frontend restarts through retry()) unpauses first: it goes
through resume(), which is valid in every state and a no-op when the game is not paused (the "only while
running" rule applies to the frontend's pause toggle, not to this internal call), so paused, get_tree().paused
and paused_changed(false) all agree and a run started from a paused state never begins frozen. It also clears the player visual's power-up state — show_aura(kind, false) for every aura kind and show_shield(false) — so a fresh run never opens wearing the last one's glow.
result fields: run_id, mode ("runner"), outcome ("caught"), score:int, score_exact:float, duration:float, distance:float, hazards_dodged:int, power_ups:int, hazards_blasted:int, hits_taken:int, recoveries:int, blocked_hits:int, seed:int, max_speed_reached:float, best_multiplier:float, chunks_spawned:int, score_rule_revision:int, submitted_at:int, rank:int (1–10, 0 = not placed), qualifies:bool, leaderboard_error:int, player_name:String.
leaderboard_error is always present and is OK (0) when the board was written. Any other value
means the write failed, and then rank is 0, qualifies is false and leaderboard_saved is not
emitted for that run.
Leaderboard entry fields: run_id, name, score:int, duration:float, seed:int, submitted_at:int.
The runner menu reads the board without a game instance via the static
preload("res://scenes/runner/systems/runner_leaderboard.gd").load_entries().
The backend owns the pause action (Esc, P, gamepad Start): it toggles pause only while running.
Gameplay input is read only while running and controls are enabled, so Space on a focused Game Over
button never jumps. The pause menu must not bind ui_cancel; Game Over binds ui_cancel →
go_to_menu() only while visible.
Every visual root is a Node3D whose script extends the backend base script named below. Backend
sets base vars before calling on_spawned(). Visuals never free themselves; the backend owns lifetime.
| Method | Notes |
|---|---|
set_anim_state(state: StringName) |
idle, run, jump, fall, roll, hurt, dead. Called on change only. After hurt the visual auto-returns to run (or idle) unless a new state arrived; the backend also sends run after land / roll_end |
set_run_speed(normalized: float) |
Every physics tick; scale run cycle speed |
set_lane_lean(direction: float) |
−1..1 during a lane change, 0 otherwise |
show_aura(kind: StringName, on: bool) |
On power-up collect / expire for lgtm, nitro, code_review. Also false for every kind on start_run (the visual outlives a run) |
show_shield(on: bool) |
On shield_changed, and false on start_run |
on_hit() |
Flash hook on unblocked hit |
Hoppy faces −Z, feet at y = 0, total height 1.8 m; the roll pose must fit inside 0.9 m.
Base vars set by the backend: kind: StringName, variant: StringName, hitbox_size: Vector3,
hitbox_offset: Vector3 (centre relative to the hazard root), lanes_mask: int (bit 0 = x −2, bit 1 = x 0,
bit 2 = x +2), dodge_hint: StringName.
| Hook | When |
|---|---|
on_spawned() |
After vars are set; reset all tweens/particles (hazards may be reused later) |
on_hit_player() |
This hazard damaged (or was blocked by) the player |
on_cleared() |
Auto-Fix removed it; play a "fixed" reaction, the hitbox is already off |
set_telegraph(t: float) |
Timed laser only: 0 → 1 during the 0.4 s before a state change, every physics tick |
set_laser_state(state: StringName) |
Timed laser only: ground, high, off |
Visual bounds must stay within 110 % of hitbox_size on every axis so what you see is what hits you.
This rule applies to the collision-volume artwork; flat ground-projection quads at y ≈ 0 whose x/z extents
equal the hitbox footprint (floor markers, safe-gap markers) are exempt on the y axis. Performance budget:
a hazard visual should produce at most ~14 draw calls (count MeshInstance3D, MultiMeshInstance3D,
GPUParticles3D and Label3D; a Label3D with an outline costs two) because up to six hazards are visible.
Base var kind. Hooks on_spawned(), on_collected(). Pickup sphere radius 0.35, centred at the root.
set_threat(t: float) (0 retreated … 1 about to catch, every tick while pursued), play_catch()
(on caught; the trap holds for the whole caught sequence, until the next run resets the threat to 0),
play_retreat() (on pursuit_recovered). The backend positions the pursuer root
at y = 0 between the camera and the player: z = lerp(12, 4.5, threat). Keep the visual ≤ 6 m wide and
≤ 2.6 m tall at threat 1 so it looms in the lower third of the frame without hiding hazards.
set_scroll_speed(speed: float) every physics tick (m/s); optional set_pursuit(t: float) (0–1).
Visual floor top at y = 0, lanes at x = −2, 0, +2, corridor ≈ 8 m wide, visible depth to z ≈ −90.
The physical floor is backend-owned and invisible once a corridor mounts. The corridor may set
get_viewport().scaling_3d_scale for a retro look; in _exit_tree it must restore the value it replaced
(two corridors can coexist briefly, e.g. menu backdrop and game, so track ownership rather than resetting to 1.0).
| Scene | Root | Extra API |
|---|---|---|
ui/runner_hud.tscn |
CanvasLayer | — |
ui/pause_menu.tscn |
CanvasLayer, process_mode ALWAYS |
Shows on paused_changed(true); buttons → game.resume(), game.retry(), game.go_to_menu() |
ui/game_over.tscn |
CanvasLayer, process_mode ALWAYS, hidden |
show_result(result: Dictionary, leaderboard: Array) is called by the frontend itself from run_finished; name entry → game.submit_name(); Retry / Menu as above; v2.5: a Retry Boss button → game.retry_boss(), shown (and given the default focus) only while game.boss_checkpoint is non-empty (§11.13) |
audio/runner_audio.tscn |
Node, process_mode ALWAYS |
SFX on bus SFX; BGM via AudioService.play_music |
runner_menu.tscn |
root named RunnerMenu |
See §2. Exposes buttons: Array[Button] with the four §2 entries (01 Play, 02 Leaderboard, 03 Return to Main Menu, 04 Quit Game); tests/runner_scaffold_check.gd asserts buttons.size() == 4 |
AudioService API available to the frontend: play_music(stream: AudioStream, volume_db := -10.0),
play_default_music(), set_music_pitch(scale: float), duck_music(db: float, seconds: float),
play_ui(kind: int) (existing: 0 hover, 1 confirm, 2 route). AudioService.music is the shared
AudioStreamPlayer on bus Music.
layer_names/3d_physics: 1 World, 2 PlayerBody, 3 EnemyHitbox, 4 PlayerHurtbox, 5 Pickup.
| Node | layer | mask | monitoring / monitorable |
|---|---|---|---|
World/Floor |
1 | 2 | — |
Player body |
2 | 1 | — |
Player/Hurtbox |
8 (4) | 0 | false / true |
| Hazard root Area3D | 4 (3) | 8 | true / false |
| Power-up root Area3D | 16 (5) | 8 | true / false |
Actions: move_left (A, ←, D-pad ←, left stick −X), move_right (D, →, D-pad →, +X), move_up
(W, ↑, D-pad ↑, stick −Y), move_down (S, ↓, D-pad ↓, +Y), jump (Space, W, ↑, gamepad A/0),
roll (Ctrl, S, ↓, gamepad B/1), pause (Esc, P, gamepad Start/6). Keys are bound by
physical_keycode. Stick deadzone 0.5 on the four move_* actions.
7. Authoritative tuning (scenes/runner/data/run_config.gd defaults; run_config.tres is the empty override resource)
| Group | Values |
|---|---|
| Lanes / body | lane_width 2.0, lane_change_time 0.13, capsule r 0.4, standing 1.8 m, rolling 0.9 m |
| Jump / roll | gravity 28, jump_velocity 9.8 (apex 1.715 m, airtime 0.70 s), super_jump_velocity 13.2 (apex 3.11 m), jump_buffer 0.10, coyote_time 0.06, roll_duration 0.70, roll_cooldown 0.25, fast-fall gravity ×2.5 |
| Speed | base_speed 12, max_speed 24 at time_to_max_speed 165 (smoothstep), then +1 m/s per 60 s to hard_cap 26; nitro_speed_multiplier 1.25 applied after the curve and then clamped: speed = min(curve_speed × 1.25, nitro_speed_cap 32) |
| Health | max_hp 2, recovery_seconds 18, hit_iframes 0.8, caught_sequence_seconds 3.0 (the trip-and-trap catch cinematic) |
| Score | base_score_rate 10 pts/s × (Nitro ×2) × (Code Review ×1.5); pickup_bonus 25; blast_bonus_per_hazard 10; fractional accumulator, integer display |
| Effects | lgtm_duration 5, nitro_duration 8 (+1 s ease-down with i-frames), Super Jump invulnerable in flight + 0.5 s, Code Review Bonus lost on unblocked hit only, refresh-not-stack |
| Spawner | spawn_horizon_z −90, despawn_z 12, max_active_chunks 9, t_spacing(d) = lerp(1.2, 0.85, d), gap = max(0, v × 1.02 × (t_spacing(d) + (0.15 if pursued else 0)) − prev.exit_clear − next.entry_clear) (parenthesised: t_spacing(d) always applies, the 0.15 s relief is added only while pursued) — the pursuit relief is inside the time term, not distance added after it; v is max(speed, base_speed), min_hazard_depth 1.2 (pits 3.0), blast_range_z 60, 50 % chunk X-mirroring, no immediate repeats |
| Power-ups | chance per chunk lerp(0.55, 0.35, d); cooldown 10 s + one chunk; weights shield 25, code_review 25, super_jump 15, nitro 15, auto_fix 10, lgtm 10; no same-kind repeat; sphere r 0.35 at y 1.0 (run) or 2.2 (jump) |
| Camera | position (0, 4, 8), look at (0, 1, −8), fov 60, lateral follow 0.4, follows in _physics_process |
| Pursuer | retreat_z 12, approach_z 4.5; threat = 0.7 · min(t_since_hit / 1.5, 1) + 0.3 · (1 − remaining / total) |
| Difficulty | difficulty = clamp(elapsed / 165, 0, 1); driven by unpaused time only |
kind is the visual scene id (hazards/visuals/<kind>_visual.tscn); variant is passed to the visual.
Per-lane hitbox width 1.6 m (x ± 0.8 around the lane centre); full width 5.6 m; two adjacent lanes 3.6 m.
Every hitbox is a BoxShape3D; depth is along Z. Hitboxes are static from spawn (slams and sweeps are
visual), except the timed laser, which switches between discrete states with the hitbox off in between.
| kind | variant | W × H × D (m) | y range | lanes | dodge_hint | CodeRabbit story |
|---|---|---|---|---|---|---|
pr_backlog_low |
– | 5.6 × 1.0 × 1.2 | 0 – 1.0 | all | JUMP |
Stacked unreviewed PR cards blocking the corridor |
pr_backlog_high |
– | 5.6 × 1.8 × 1.2 | 1.1 – 2.9 | all | ROLL |
Hanging PR cards; roll under |
bug_swarm |
– | 1.6 × 0.9 × 1.2 | 0 – 0.9 | 1 | JUMP_OR_LANE |
Glowing bug icons at ground level |
merge_conflict |
– | 3.6 × 1.0 × 1.2 | 0 – 1.0 | 2 adjacent | JUMP_OR_LANE |
Two branches slammed together (<<<<<<< HEAD / >>>>>>>) |
deprecated_pit |
full / two_lane |
5.6 or 3.6 × 0.4 × 3.0 | −0.2 – 0.2 | all / 2 | JUMP / JUMP_OR_LANE |
Crumbled @deprecated floor |
laser_grid |
ground |
5.6 × 0.4 × 1.2 | 0.1 – 0.5 | all | JUMP |
CI security scan beam |
laser_grid |
mid |
5.6 × 0.3 × 1.2 | 1.0 – 1.3 | all | ROLL_OR_JUMP |
Roll is safe, jump is expert |
laser_grid |
high |
5.6 × 0.4 × 1.2 | 1.1 – 1.5 | all | ROLL |
Chest-height beam |
laser_grid |
timed |
cycle of 3.0 s from the run clock: ground on 1.2 s → off 0.3 s → high on 1.2 s → off 0.3 s (hitbox off in both off states) |
ground / high rows | all | TIMED |
Sweeping scanner |
comment_flood |
– | 3.6 × 4.0 × 1.2 | 0 – 4.0 | 2 adjacent | LANE |
Wall of nitpick comments; too tall to jump |
build_shockwave |
– | 5.6 × 0.5 × 1.2 | 0 – 0.5 | all | JUMP |
Failed-build blast ring |
Power-up kinds: approve_shield, auto_fix, lgtm, nitro, super_jump, code_review.
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --import
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/runner_logic.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/runner_chunk_validator.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/runner_smoke.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/runner_boss_validator.gd # v2, see §11.12
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/runner_visual_smoke.gd
/Applications/Godot.app/Contents/MacOS/Godot --headless --path hoppys-adventure-arcade --script res://tests/menu_smoke.gdPer-file lint: --headless --path hoppys-adventure-arcade --script res://<file>.gd --check-only.
Baseline note (2026-09-19): on the installed Godot 4.7.0 the pre-existing menu_smoke.gd reports two
mouse-hover failures on a clean main; the docs validated it on 4.7.1. New runner tests must pass fully.
- 2.7 (2026-10-10): §11 — the automatic turn is gone and the corridor stays straight. Phases are
run → boss → stage_clear: the drain at the end ofrunhands straight tobosson the tick it ends. Removed: the three turn signals (announcement, start, finish); propertynext_turn_direction; result fieldturns_taken; checkpoint keysturns_takenandnext_turn_direction; the whole RunConfig Turn group (bend length, warning lead, graybox yaw, camera yaw and bank, swing fractions); the corridor's turn hook and the camera swing.World/TurnRigkeeps its name as a static mount. §11.15 — the run intro: a newintrophase opens every run from stage 1 (full 6.0 s on a fresh run from the menu, short 2.2 s onretry()); a boss-checkpoint retry has none, and the boss's own short intro is its lead-in. The intro is not run time:elapsed,distanceand the score's base rate start at the hand-over torun. New facade signalsrun_intro(mode, seconds),run_intro_beat(index, label),run_intro_skipped(),run_intro_ended(); read-onlyrun_intro_mode,run_intro_progress; methodscan_skip_run_intro(),skip_run_intro(); staticpending_intro_mode; RunConfig group "Run intro";camera_rig.set_run_intro(progress, mode, focus)reading the frontend'sdata/run_intro_shots.gd; optional HUD mountui/run_intro.tscn. The player is held through the cinematic hold until the GO beat, and a jump /ui_acceptskips after 0.3 s and before GO. The StageManager's unusedstage_rng/STAGE_SEED_MIXare gone (the encounter'sfight_rngdraws every boss roll). - 2.6 (2026-10-09): §11.14 — boss cinematics. The intro becomes a camera cinematic: full (
boss_intro_seconds3.0 → 5.0) the first time a run meets a boss, short (boss_intro_short_seconds2.5) on every later meeting and on a checkpoint retry; the kill plays a defeat cinematic (boss_defeat_seconds3.6, short 2.0 on a repeat defeat) that holds the boss phase open in place ofboss_outro_seconds, which now applies to a retreat only. New facade signalsboss_cinematic(kind, mode, seconds),boss_cinematic_skipped(mode),boss_cinematic_ended(mode); read-onlyboss_cinematic_mode,boss_cinematic_progress,boss_time_factor; methodskip_boss_cinematic()(intros only: jump /ui_acceptonceboss_cinematic_skip_after0.5 s have played, jumping to the lastboss_cinematic_skip_tail0.3 s; a defeat is never skippable). While a cinematic holds the player, manual controls are off and the corridor eases toboss_cinematic_speed_scale0.25×.boss_time_factorcarries the defeat's hit-stop (0.05 forboss_hitstop_seconds0.12) and slow-mo (boss_slowmo_factor0.4 eased back to 1 overboss_slowmo_seconds0.8);Engine.time_scaleis never touched.camera_rig.gdgainsset_boss_cinematic(progress, mode, boss_position, focus_position)(the lens sits off the mount and aims at the focus;focus_positionis optional and defaults to the mount) and the pureboss_shot(p, mode)(blend 0 at both ends). §11.5 — optional visual hooksplay_cinematic(mode, seconds)(set-up cue),set_cinematic_progress(mode, progress)(every cinematic tick, after a skip, and 1.0 at the end; visuals pose from it) andset_time_factor(factor)(ambient motion only), pluscinematic_focus() -> Vector3(the camera's aim; default: the centre of the upper third of the rest bounds) and the shared intro timing constantsINTRO_POSE_AT/INTRO_HOLD_UNTILonboss_visual_base.gd. In a run,play_cinematic(intro_*)is the only intro cue:boss_fxno longer forwardsboss_introtoplay_intro(seconds), which had started visual intros twice.play_introstays an optional hook for standalone and dev fixtures. §11.8 — optional HUD mountui/boss_cinematic.tscn. §11.13 — the checkpoint gainsbosses_metandbosses_defeated_kinds.boss_intro(kind, name, seconds)keeps its signature; scoring is unchanged (boss_defeatedand its bonus land on the same tick as in 2.5). - 2.5 (2026-09-30): §11.13 — boss checkpoint retry. Every boss entry snapshots the run into
boss_checkpoint; a catch mid-fight keeps it and Game Over offers Retry Boss (retry_boss()), which reloadsrunner_gameon the same seed at the turn into that stage's boss with the score, clocks and tallies restored and full HP. New facade propertiesboss_checkpoint,checkpoint_retry, staticpending_boss_checkpoint; StageManagerbegin_at_boss(checkpoint); result fieldcheckpoint_retry. - 2.4 (2026-09-28): §11.4/§11.5/§11.6/§11.10/§11.12 — boss signature attacks. Beats may carry a
signature(boss_defs.gd): Flaky Test Swarmflaky(the telegraph alternates two candidate lanes forboss_flaky_candidate_seconds, then commits at leastboss_commit_minbefore fire) andscatter(a half-speed orb down the middle that splits into the two outer lanes at track z −boss_scatter_split_distance); Merge Conflict Hydrabranching(staggered heads, each telegraphed up front on its own lane),sweep(the low rebase sweep sliding outer lane → outer lane in flight) andforce_push(phase 2's fourth head: a fastwide, telegraphed 1.6× longer); Legacy Monolithlegacy_wall(a slow wall of slabs with one gap that shifts once to the next lane),quake(full-width ground shockwave, jump only) anddebris(slabs falling onto floor shadows in 1–2 lanes). New facade signalsboss_attack_planned(attack)andboss_attack_committed(lane_mask, kind, seconds);boss_attack_telegraphnow carries the candidate mask for a flaky beat; six new projectile variants (scatter,sweep,force_push,slab,quake,debris), each mapped to a base look for visuals that do not opt in; projectiles exposeboss_kind,signature,motion,ageand the motion APIlane_mask_at(t)/y_range_at(t)/impact_lane_mask()(boss/boss_attacks.gd); the autopilot andrunner_boss_validator.gdplan against that API, and the validator gains B6. Beats without asignaturebehave exactly as in 2.3. The Hydra taunt now matches its head count (3 heads, 4 from phase 2). - 2.3 (2026-09-22): §11.1/§11.8/§11.10 — the stage-entrance guarantee. The
STAGE nbanner covers the lanes, so forstage_banner_clear_seconds(3.5) after everystage_started, stage 1 included, no blocking hazard may reach the player, and when that window ends the nearest one is stillstage_banner_reaction_seconds(1.0) × speed metres away.chunk_spawner.gdgainsbegin_clear_road(seconds), which the StageManager calls immediately before switching placement back on: it clamps a frontier left stale by a drained corridor to at least 6 m ahead (so the first chunk of a stage is never parked at the player's feet), sweeps anything already inbound silently (nohazard_cleared, nohazards_blasted, no bonus, no score, nohazards_dodged), and restricts placement to hazard-freebreatherchunks until a hazard-bearing one would arrive late enough.breather-tagged chunks are now required to carry zero hazards.ui/stage_banner.tscnmust be off screen withinstage_banner_clear_seconds − stage_banner_reaction_seconds. - 2.2 (2026-09-21): §11.3/§11.8 —
ui/post_fx.tscnsanctioned as an optional HUD mount (CanvasLayer 9, PAUSABLE, idempotenthas_signal-guardedbind(game)): one full-screen post pass for grade, grain, vignette, aberration, letterbox, speed streaks and Nitro lift, with the HUD keeping the pursuit vignette and corner bars unless the frontend hands them over viapost_fx.set_settings(). - 2.1 (2026-09-21): §11.4 — a turn announcement signal warns of the corner 3.0 s ahead of the bend, in the direction the bend then takes, with
next_turn_directionon the facade;boss_intro(kind, name, seconds)opens the fight immediately beforeboss_spawned. §11.9/§11.10 — the camera swing becomes a ramp–hold–return envelope (0.3 swing fractions), retuned to 55° yaw / 12° bank over a 2.4 s bend;boss_intro_seconds1.2 → 3.0. (Turns removed in 2.7.) - 2.0 (2026-09-19): §11 v2 addendum — run phases (run → turn → boss → stage_clear),
StageManager,TurnRig, boss encounters with telegraphed projectiles and auto-fire tool pickups, faster speed ramp (time_to_max_speed100 s + per-stage bumps), pursuit alert signals, new facade signals/properties/result fields, boss visual hooks, projectile hitbox table, tool table, optional UI mounts (pursuit_alert,boss_hud,stage_banner,stage_clear), a corridor turn hook, leaderboardSCHEMA_VERSION2 with in-place migration of v1 boards. - 1.0 (2026-09-19): initial contract reconciled from the backend, procgen/tuning and frontend design passes.
- 1.3 (2026-09-19): §4.3 —
result.leaderboard_error:intadded; a failed leaderboard write keepsrank0 /qualifiesfalse and emits noleaderboard_saved. A board whosemeta/versionis newer than the supported schema is one such failure: it reads as empty andsubmit/renamerefuse to overwrite it, returningERR_FILE_UNRECOGNIZED. - 1.2 (2026-09-19): §5.2 — ground-projection quads exempt from the 110 % rule on y; per-hazard draw-call budget stated.
- 1.1 (2026-09-19): engine version policy (4.7.1 pin, 4.7.0 validation); AppRouter rule scoped to in-game overlays; routes wording clarified; leaderboard writes are atomic through a
.tmpand a temporary.bakthat is removed once the new board is in place, so a successful write leaves neither behind (a.baksurvives only a crash mid-write, andload_entriesfalls back to it); Nitro cap arithmetic and viewport-scale restore semantics clarified; return-type rule generalised; mount resources validated asPackedScene; timed laser cycle spelled out.
Everything in §1–§10 stays in force unless a rule below replaces it. The user decisions behind v2: endless
with escalating stages (never a finite campaign) and tools auto-fire on pickup (no fire button). v2.0–2.6
also had automatic cinematic turns between a stage's run and its boss; v2.7 removed them, and the corridor stays
straight. Backend detail lives in docs/endless-runner-systems.md §10.
state is unchanged (ready → running → caught → finished). A new orthogonal read-only property
phase: StringName ∈ {idle, intro, run, boss, stage_clear} is owned by Systems/StageManager and emitted through
phase_changed(phase). caught aborts any phase to idle.
| Phase | Enters | Ends | Spawner | run_clock |
|---|---|---|---|---|
intro |
v2.7 (§11.15): start_run() when a run intro is due (never on a checkpoint retry) |
its length (run_intro_full_seconds / run_intro_short_seconds), or the skip tail; then run |
idle | frozen |
run |
start_run() (after the intro) or after stage_clear |
stage_distance reached and no blocking hazard ahead of stage_clear_line_z (drain; hard timeout stage_drain_timeout then remaining hazards are cleared silently, no bonus) |
places chunks until target − stage_drain_distance, then only moves/despawns |
advances |
boss |
run drained (on the same tick) | boss HP 0 (boss_defeated), then the defeat cinematic (§11.14); or boss_timeout_seconds (boss_retreated), then boss_outro_seconds |
idle | frozen |
stage_clear |
boss resolved | stage_clear_seconds |
idle | frozen |
Stage-entrance guarantee (v2.3). The STAGE n banner covers the lanes, so the corridor is held clear across
it: for stage_banner_clear_seconds after every stage_started (stage 1 included) no blocking hazard may
reach the player, and when that window ends the nearest blocking hazard is still at least
stage_banner_reaction_seconds × speed metres away. The StageManager opens the window on spawner .begin_clear_road(stage_banner_clear_seconds) immediately before it switches placement back on, so the first
chunks of the stage are already measured against it. Inside the window the spawner may only place a chunk whose
nearest hazard arrives after the window plus the reaction time; anything else is swapped for a hazard-free
breather chunk, and breather-tagged chunks therefore carry no hazards at all. The road still fills to
spawn_horizon_z — breathers and power-ups keep the pacing — it is never simply left empty. Anything already
inbound when the window opens (a leftover boss projectile included) is cleared silently: this is
housekeeping, not Auto-Fix, so no hazard_cleared, no hazards_blasted, no bonus, no score and no
hazards_dodged. The window counts down with the corridor, so a pause holds it.
elapsed still counts every unpaused second (result duration, timed-laser phase). run_clock counts RUN-phase
seconds only and drives speed and difficulty. Stage index starts at 1 and never resets; boss slot
= (stage_index − 1) % 3, cycle c = (stage_index − 1) / 3 (integer division).
speed_at(run_clock) = lerp(base_speed + b, max_speed + b, smoothstep(0, 1, clamp(run_clock / time_to_max_speed, 0, 1)))
with the tail + tail_speed_per_minute (RunConfig, default 1.0 m/s) per 60 s of run_clock past time_to_max_speed,
capped at hard_cap_speed + b (RunConfig, default 26.0; §7's "hard_cap 26" and "+1 m/s per 60 s" name these two fields), where the stage bonus b = min(stage_speed_step × (stage_index − 1), stage_speed_bonus_max).
Defaults: time_to_max_speed 100 (was 165), stage_speed_step 1.0, stage_speed_bonus_max 6.0. Nitro still
multiplies by nitro_speed_multiplier and caps at nitro_speed_cap (raised to 38).
difficulty = max(clamp(run_clock / time_to_max_speed, 0, 1), min(stage_difficulty_step × (stage_index − 1), 1))
with stage_difficulty_step 0.25; chunk tiers, weights and the §8 hazard table are unchanged.
World
├─ Floor, Chunks unchanged, never rotated
└─ TurnRig (Node3D, systems/turn_rig.gd) v2.7: a static mount; the name is kept, nothing rotates it
├─ GrayboxEnvironment moved from World/
├─ Environment moved from World/; mount environment/corridor.tscn
└─ BossArena (boss/boss_arena.tscn, boss/boss_encounter.gd)
├─ Boss (Node3D) └─ Visual mount boss/visuals/<kind>_visual.tscn
├─ Projectiles (Node3D) hazards/projectile.tscn instances
├─ Tools (Node3D) powerups/tool_pickup.tscn instances
└─ (mount boss/boss_fx.tscn) optional 3D fight effects, PAUSABLE, `bind(game)`
Systems
└─ StageManager (systems/stage_manager.gd), inserted after Spawner, before Autopilot
HUD mounts (optional, PAUSABLE, `bind(game)`): ui/pursuit_alert.tscn, ui/boss_hud.tscn,
ui/stage_banner.tscn, ui/stage_clear.tscn,
ui/boss_cinematic.tscn (v2.6, §11.14),
ui/run_intro.tscn (v2.7, §11.15),
ui/post_fx.tscn (CanvasLayer 9, under the panels)
Gameplay never rotates: chunks, hazards, projectiles, tools and the player stay in the World frame, so every
z-test (despawn_z, blast_range_z, dodge line, autopilot) is unchanged. RunnerGame.track_z(node) /
track_aabb(node) are the rig-local accessors backend code uses (the identity, since the rig never rotates).
Signals (additive; §4.1 unchanged):
| Signal | Emitted by | Notes |
|---|---|---|
phase_changed(phase: StringName) |
StageManager | on change only |
stage_started(index: int, name: StringName, speed: float) |
StageManager | name is the stage's boss display name, e.g. &"Flaky Test Swarm"; speed = base speed for the stage |
stage_cleared(index: int, bonus: int) |
StageManager | after boss_defeated/boss_retreated; bonus already added to score |
boss_intro(kind: StringName, name: String, seconds: float) |
BossEncounter | at the very start of the intro, immediately before boss_cinematic and boss_spawned; seconds = the intro window in which no attack and no tool is spawned — v2.6: boss_intro_seconds on the run's first meeting with this boss, min(boss_intro_short_seconds, boss_intro_seconds) on a repeat (§11.14). A skip shortens the window to the skip tail |
boss_cinematic(kind: StringName, mode: StringName, seconds: float) |
BossEncounter | v2.6 (§11.14): a cinematic of seconds opens. intro_full / intro_short right after boss_intro (same seconds) and before boss_spawned; defeat_full / defeat_short right after boss_defeated, on the same tick. Never sent for a zero-length cinematic |
boss_cinematic_skipped(mode: StringName) |
BossEncounter | v2.6: the player skipped an intro (intro_full / intro_short; a defeat is never skippable); it jumps to its last boss_cinematic_skip_tail seconds, and boss_cinematic_ended still follows when that tail has played |
boss_cinematic_ended(mode: StringName) |
BossEncounter | v2.6: exactly once per boss_cinematic: when it has played out, after a skip's tail, or at once when the fight is aborted (a catch, a new run) |
boss_spawned(kind: StringName, hp: int) |
BossEncounter | right after boss_intro (and boss_cinematic): the moment the boss is visible and armed. Attacks begin seconds later, when the intro boss_intro announced has run down |
boss_health_changed(hp: int, max_hp: int) |
BossEncounter | |
boss_phase_changed(phase: int) |
BossEncounter | 1-based; at 100 % / 66 % / 33 % HP |
boss_attack_planned(attack: Dictionary) |
BossEncounter | v2.4: immediately before every boss_attack_telegraph, on the same tick: the full attack that telegraph announces (§11.6 attack dictionary). A branching volley sends one pair per head |
boss_attack_telegraph(lane_mask: int, kind: StringName, seconds: float) |
BossEncounter | lane_mask bit i = lane index i (§11.7); kind = projectile variant. v2.4: lane_mask is the telegraphed lanes — both candidates for a flaky beat, the whole path for a sweep, the first gap's slabs for a legacy wall, the middle for a scatter; seconds is the whole telegraph (candidates + commit for a flaky beat) |
boss_attack_committed(lane_mask: int, kind: StringName, seconds: float) |
BossEncounter | v2.4: a flaky telegraph stopped alternating and settled on lane_mask (one of its candidates); seconds = what is left before it fires, always ≥ boss_commit_min. Commits arrive in telegraph order |
boss_projectile_fired(projectile: Area3D, lane_mask: int, kind: StringName, speed: float) |
BossEncounter | at the end of the telegraph, once per projectile; lane_mask = the lanes that projectile starts in, kind = its variant, speed = its own closing speed (a signature beat may scale it) |
boss_defeated(kind: StringName, bonus: int) / boss_retreated(kind: StringName, bonus: int) |
BossEncounter | exactly one of the two per fight; bonus is already added to the score. v2.6: a defeat is followed on the same tick by its boss_cinematic, and stage_cleared waits for that cinematic to end |
run_intro(mode: StringName, seconds: float) |
StageManager | v2.7 (§11.15): the run intro opens; mode is &"full" or &"short"; phase is already intro and the mirrors are set |
run_intro_beat(index: int, label: String) |
StageManager | v2.7: each beat as the intro's clock reaches it, in index order; full title, 3, 2, 1, GO!, short READY, GO!; the last beat (GO) hands the player back before it is sent |
run_intro_skipped() |
StageManager | v2.7: the player skipped; the intro jumps to its last run_intro_skip_tail seconds, the beats jumped over are never sent and the GO beat follows at once |
run_intro_ended() |
StageManager | v2.7: exactly once per run_intro: played out, after a skip's tail, or at once on an abort (a catch, a new run) |
tool_collected(kind: StringName, damage: int) |
BossEncounter | damage 0 when the boss's phase resists the tool |
pursuit_alert(kind: StringName, seconds_to_catch: float) |
HitManager | warn at pursuit start, critical when recovery_remaining ≤ pursuit_critical_seconds, clear on recovery or catch |
pursuer_distance_changed(normalized: float) |
Pursuer | 0 retreated … 1 catching; epsilon-gated |
Read-only properties: phase, stage_index: int, stage_name: StringName, stage_distance: float,
stage_distance_target: float, run_clock: float, boss: Node3D (null outside a fight), boss_kind, boss_hp,
boss_max_hp, boss_phase: int; v2.6 (§11.14) boss_cinematic_mode: StringName (&"" outside a cinematic),
boss_cinematic_progress: float (0..1), boss_time_factor: float (1 outside a defeat); v2.7 (§11.15)
run_intro_mode: StringName (&"" outside an intro) and run_intro_progress: float (0..1). Methods: v2.7
skip_run_intro() -> bool and can_skip_run_intro() -> bool; v2.6
skip_boss_cinematic() -> bool and can_skip_boss_cinematic() -> bool (read-only: true while a skip would take, for
a skip prompt; §11.14), get_stage_info() -> Dictionary
(index, name, phase, distance, target, speed_bonus, difficulty_floor), get_boss_info() -> Dictionary
(kind, name, hp, max_hp, phase, phase_count, accepted_tools, pending_attacks; phase_count is the fight's own
phase count, 3 today, and 0 in the empty block; each pending_attacks row is {lane_mask, kind, remaining} plus,
v2.4, {impact_mask, signature, committed, commit_remaining, attack}, where lane_mask switches from the candidates
to the committed lanes at the commit). result gains stages_cleared,
stage_reached, bosses_defeated, bosses_escaped, tools_used, boss_damage_dealt (all int; v2.7 dropped turns_taken);
v2.5 adds checkpoint_retry: bool (§11.13).
Projectiles count in hazards_dodged (it is a stat, not a score input). camera_rig.shake(0.35) fires on every
damaging tool hit (backend-owned, as in §4.2).
Kinds: flaky_test_swarm, merge_conflict_hydra, legacy_monolith. Base vars set before on_spawned():
kind, boss_name: String, max_hp: int, phase_count: int, hover_z: float, sway_amplitude: float,
sway_period: float. The visual sways itself in _process (PAUSABLE); the backend never moves it per tick.
| Hook | When |
|---|---|
on_spawned() |
once, after the vars are set; idempotent |
set_phase(phase: int) |
on boss_phase_changed |
set_health(hp: int, max_hp: int) |
on boss_health_changed |
play_telegraph(lane_mask: int, kind: StringName, seconds: float) |
on boss_attack_telegraph |
play_commit(lane_mask: int, kind: StringName, seconds: float) |
v2.4, optional (has_method-guarded): on boss_attack_committed |
play_attack(kind: StringName) |
at fire |
play_hit(tool_kind: StringName, damage: int) |
damaging tool |
play_resist(tool_kind: StringName) |
resisted tool (damage 0) |
play_defeat() / play_retreat() |
fight end. v2.6: after a defeat the backend frees the boss once the defeat cinematic is over (§11.14); after a retreat, after boss_outro_seconds |
play_cinematic(mode: StringName, seconds: float) |
v2.6, optional (has_method-guarded): a cinematic opens; this is the cue to set up. intro_* arrives straight after on_spawned() (alongside boss_intro), and defeat_* straight after play_defeat(). In a run, the encounter's play_cinematic(intro_*) is the only intro cue: boss_fx no longer forwards boss_intro to the visual. A visual may keep an optional play_intro(seconds) for standalone and dev fixtures, but nothing in a run calls it |
set_cinematic_progress(mode: StringName, progress: float) |
v2.6, optional: where the cinematic is, 0..1. It is called with 0 right after play_cinematic(), on every physics tick while the cinematic runs, once straight after a skip with the jumped progress, and once with 1.0 when it ends (played out, skipped tail done, or aborted). During a cinematic the visual's pose, dissolve, flash and strobe are functions of progress: the action is seeked to progress-mapped time, with whatever hold or strobe sub-ranges the model rules give it, never an accumulated clock. That way a pause, a skip and the 6× smokes stay in step for free |
set_time_factor(factor: float) |
v2.6, optional: every tick of a cinematic, and 1.0 when it ends: 0.05 through the defeat hit-stop, then slow-mo easing back to 1. It scales only the visual's ambient motion during the slow-mo tail (idle sway, particles), never the cinematic pose, which set_cinematic_progress() owns |
cinematic_focus() -> Vector3 |
v2.6, optional: the point, in the visual's local space, the cinematic camera aims at. The encounter reads it once, straight after on_spawned() and before any cinematic cue, carries it into the Boss mount's space and keeps it for the fight (boss_encounter.cinematic_focus), so no later pose can move the aim. The base default (also applied to the graybox and to any visual without the hook) is the centre of the upper third of the combined rest bounds of every visible MeshInstance3D: each mesh's own AABB, unskinned, through its transform chain (boss_visual_base.gd static rest_bounds() / rest_focus()). That is about 2.4 / 3.1 / 2.8 m above the mount for the Swarm / Hydra / Monolith. Override it to aim at a head or a core |
Envelope: a bounding box ≤ 7 m wide × 3 m deep with its vertical extent inside y ∈ [1.6, 6.1] (bottom edge never
below y 1.6; 4.5 m of headroom), core nominally centred around y 2.6 at hover_z; ≤ 40 draw calls at peak.
Entrance exception (v2.1): during the boss_intro window the visual may travel in from the far end, but at every
frame its bounds must still satisfy x ∈ [−3.5, 3.5] and y ∈ [1.6, 6.1]; only z may extend beyond the box, and only
toward the far end (never closer to the player than hover_z + 3). Nothing rises from under the floor. The boss has no hitbox. Graybox fallback: a tinted box with a Label3D name plate (backend).
The same envelope holds through every §11.14 cinematic: the camera moves to the boss, the boss never moves to the camera.
11.6 Projectiles (extends §8) — hazards/visuals/projectile_visual.tscn extends hazards/hazard_visual_base.gd
kind = &"projectile", variant selects the shape; the base also receives closing_speed: float before
on_spawned(). Projectiles are hit-once hazards in group runner_hazards (Auto-Fix clears them), spawned at the
boss and moving +Z at closing_speed relative to the player.
| variant | W × H × D (m) | y range | lanes | dodge_hint |
|---|---|---|---|---|
orb |
1.6 × 0.9 × 1.2 | 0–0.9 | 1 | JUMP_OR_LANE |
low |
5.6 × 0.9 × 1.2 | 0–0.9 | all | JUMP |
high |
5.6 × 0.4 × 1.2 | 1.1–1.5 | all | ROLL |
wide |
3.6 × 1.0 × 1.2 | 0–1.0 | 2 adjacent | LANE |
Visuals ≤ 110 % of the hitbox (ground-projection quads exempt on y as in §5.2). Reaction budget guaranteed by
tests/runner_boss_validator.gd: telegraph + travel ≥ 0.98 s at maximum escalation, jump reaction ≥ 0.35 s.
Signature variants (v2.4). Same rules as the four above; look is the base variant a visual that has not opted in
is handed as variant (so nothing is ever invisible), boss the boss that fires it:
| variant | W × H × D (m) | y range | lanes | dodge_hint | look | boss / signature |
|---|---|---|---|---|---|---|
scatter |
1.6 × 0.7 × 1.2 | 0–0.7 | 1 | JUMP_OR_LANE | orb |
Swarm scatter (half speed) |
sweep |
1.6 × 0.6 × 1.2 | 0–0.6 | 1 (moving) | JUMP_OR_LANE | orb |
Hydra sweep (0.55× speed) |
force_push |
3.6 × 1.0 × 1.2 | 0–1.0 | 2 adjacent | LANE | wide |
Hydra force_push (1.3× speed, 1.6× telegraph) |
slab |
1.8 × 2.4 × 1.2 | 0–2.4 | 1 | LANE | wide |
Monolith legacy_wall (0.4× speed) |
quake |
5.6 × 0.5 × 1.2 | 0–0.5 | all | JUMP | low |
Monolith quake |
debris |
1.6 × 0.8 × 1.2 | 0–0.8 landed | 1 | JUMP_OR_LANE | wide |
Monolith debris (0.55× speed) |
flaky and branching fire plain orbs. A signature beat's speed is the fight's projectile speed × its speed_scale,
capped at boss_signature_speed_max; its telegraph is the phase telegraph × telegraph_scale, floored at
boss_telegraph_min.
Motion (v2.4). boss/boss_attacks.gd is the single source (pure, static). A shot is one projectile's motion,
a function of t = seconds since that projectile was fired: z(t) = from_z + speed·t (every shot still flies +Z at a
constant speed; from_z = boss_z), x(t) piecewise linear through x_keys [(t, x), …], and a y lift that only a
falling debris slab has (drop_height·(1 − (t/land_at)²) until land_at, 0 after). The projectile node exposes
boss_kind: StringName, signature: StringName, motion: Dictionary (the shot; empty for a straight shot, which
moves exactly as in 2.3), age: float (= t), and lane_mask_at(t) -> int (lanes its hitbox touches at t, by the
occupies_lane rule), y_range_at(t) -> Vector2 (world y min/max at t) and impact_lane_mask() -> int (the lanes
it covers while its z range overlaps the player's, which is what a dodge and the §11.7 tool-lane rule answer). The
root follows the motion (x and y), so get_world_aabb() and the physics hitbox are always where the motion says.
Attack dictionary (boss_attack_planned, pending_attacks[].attack): signature, variant, boss_kind,
telegraph_mask, mask (the lanes at fire, the committed lanes for a flaky beat), impact_mask, telegraph,
speed, from_z, shots (Array of shot dictionaries: variant, speed, from_z, lanes, x_keys, drop_height, land_at, twin), enter / exit (seconds after the telegraph during which it passes the player), jumpable,
rollable, clear_height, and per signature: flaky commit_at (seconds after the telegraph), candidates
(the two masks), swap_seconds; scatter split_at (seconds after fire), split_mask; branching head, heads,
delay; sweep sweep_from, sweep_to (lane indices), sweep_end (seconds after fire); legacy wall gap_from,
gap_to, shift_at; debris drop_height, land_at. A scatter's second half carries twin: true in its shot (it
flies inside the first until the split, so a visual may hide it until then). A visual that wants the real variant
declares var signature_variants := true; any visual declaring projectile_variant, boss_kind, signature or
motion receives them before on_spawned().
Tools are pickups (powerups/tool_pickup.tscn, extends power_up.gd, group runner_tools, layer Pickup) that
auto-fire on pickup: collecting one deals its damage to the boss immediately (or 0 if resisted) and adds
tool_bonus score. Lane indexing everywhere: index 0/1/2 ↔ x −2/0/+2; lane_mask bit i = lane index i.
| kind | display | damage | weight | spawn y |
|---|---|---|---|---|
review_comment |
Review Comment | 10 | 35 | 1.0 |
lint_rule |
Lint | 12 | 25 | 1.0 |
unit_test |
Unit Test | 15 | 25 | 1.0 |
auto_fix_patch |
Auto-Fix | 20 | 15 | 2.2 (jump height) |
Boss phases that resist all but specific tools: Hydra phase 2 accepts only unit_test, lint_rule; Monolith
phase 2 accepts only auto_fix_patch, review_comment; Monolith phase 3 accepts only unit_test. Everything
else accepts all four. Tools spawn only during boss, from boss_tool_spawn_z, in a lane with no live projectile
within boss_tool_lane_clearance, never the same lane twice in a row; a fight never goes more than
boss_tool_guarantee_seconds without a tool in flight. Existing power-ups keep working in fights (Auto-Fix clears
projectiles; Nitro/LGTM invulnerability applies; Shield blocks a projectile).
The one non-UI mount in this table is boss/boss_fx.tscn: it mounts under World/TurnRig/BossArena (§11.3) rather
than under HUD, and it is the sanctioned home for 3D fight effects, so no panel has to reach into World.
| Scene | Purpose | Reads |
|---|---|---|
ui/pursuit_alert.tscn |
Star Fox-style incoming-transmission panel with radar/pulse; states the catch rule | pursuit_started, pursuit_progress, pursuit_alert, pursuit_recovered, damage_blocked, caught |
ui/boss_hud.tscn |
boss name plate, HP bar, phase pips | boss_* signals, get_boss_info() |
ui/stage_banner.tscn |
STAGE n / <boss name> banner, raised on stage_started unless a boss is on screen. It covers the lanes, so it must be fully off screen within stage_banner_clear_seconds − stage_banner_reaction_seconds (2.5 s at the defaults) of the stage_started that raised it — the backend only holds the corridor clear for stage_banner_clear_seconds (§11.1), and the reaction time is the player's, not the banner's |
stage_started, boss_intro, boss_spawned, phase_changed |
ui/stage_clear.tscn |
stage-clear card (time, tools used, bonus) | stage_cleared, boss_defeated/boss_retreated, get_stage_info() |
ui/boss_cinematic.tscn |
v2.6 (§11.14): the boss cinematic overlay (chyron, title card, threat line, hint row, skip prompt, defeat banner). Driven only off boss_cinematic_progress and boss_time_factor — no Tweens or timers of its own — so a pause or a skip can never put it out of step |
boss_cinematic, boss_cinematic_skipped, boss_cinematic_ended, boss_defeated, the boss_cinematic_* properties, get_boss_info() |
ui/run_intro.tscn |
v2.7 (§11.15): the run intro overlay (title card, countdown, GO burst, skip prompt). Driven only off run_intro_progress and the run_intro_* signals, no clock of its own; clears on run_intro_ended. Hoppy's victory pose under the title is the backend's (the title beat calls the player visual's celebrate()) |
run_intro, run_intro_beat, run_intro_skipped, run_intro_ended, run_intro_mode / run_intro_progress, can_skip_run_intro(), skip_run_intro() |
ui/post_fx.tscn |
one full-screen post-processing pass: colour grade, film grain, vignette, chromatic aberration on damage and tool hits, letterbox for every boss cinematic and the stage-clear card, speed streaks, Nitro lift. v2.6 (§11.14): the cinematic letterbox comes in with boss_cinematic, follows boss_cinematic_progress, eases out over the last boss_cinematic_skip_tail once skipped, and closes on boss_cinematic_ended; its own clock runs at boss_time_factor |
run_started, damage_taken, tool_collected, boss_cinematic / boss_cinematic_skipped / boss_cinematic_ended with the boss_cinematic_progress / boss_time_factor properties (v2.6), stage_cleared, phase_changed, run_finished, caught, power_up_collected/power_up_expired, pursuit_started/pursuit_progress/pursuit_recovered, speed_changed, paused_changed |
boss/boss_fx.tscn |
3D fight effects: telegraphs, tracers, damage numbers; never touches gameplay nodes | boss_* signals, tool_collected, get_boss_info() |
ui/post_fx.tscn is a CanvasLayer at layer 9 — below runner_hud.tscn (10) and every other panel, so the
grade and the grain never wash out a gameplay-critical label — PROCESS_MODE_PAUSABLE like the rest, and its
bind(game) must be idempotent (a second bind rebinds nothing and doubles no effect) and connect only
signals it has confirmed with game.has_signal(...), because the mount is sanctioned ahead of the signals some of
its effects want. It reads state and never calls a mutating facade method.
Effect ownership. The HUD keeps ownership of the pursuit vignette and the corner bars (§11.8 pursuit_alert
/ the F7 legibility pass): post_fx must not draw a second pursuit vignette or a second set of corner bars unless
the frontend explicitly hands them over through post_fx.set_settings() (the frontend-owned settings entry point;
the backend never calls it). Absent that hand-over, post_fx's pursuit response is limited to the grade and the
aberration, so the two layers can never stack the same effect.
The HUD mount point may carry additional frontend-owned children beyond the scenes in this table; the backend
asserts only that its own contract mounts are present exactly once, never how many children the layer holds.
ui/runner_hud.tscn, ui/pause_menu.tscn, ui/game_over.tscn keep their v1 contract; the frontend may restyle
them for legibility (font ≥ 18 units, outline ≥ 6, panel-backed; see the v2 frontend design §1). The F3
DebugOverlay (backend) prints phase, stage, boss HP/phase and pending attacks so graybox play stays readable.
Optional methods, all has_method-guarded by the backend: set_phase(phase: StringName) and
set_boss_arena(active: bool). The StageManager reaches the mounted corridor through TurnRig.corridor. Mounting a
corridor still frees GrayboxEnvironment (§3). v2.7: the corridor is straight and never rotates; the v2.0–2.6
corridor turn hook, the TurnRig's fallback yaw and the camera's turn swing are gone. The starship
corridor closes both ends at every scroll offset: the far bulkhead past the spawn horizon, and an entry
bulkhead behind Hoppy (z ≈ +24) that the run intro's reverse shots (§11.15) land on and gameplay never sees.
11.10 RunConfig additions (single tuning source: the @export defaults in run_config.gd; run_config.tres stays an empty resource that may override them and is never mutated at runtime — this supersedes the §7 heading's wording)
Speed: time_to_max_speed 100.0 (was 165), tail_speed_per_minute 1.0, hard_cap_speed 26.0 (unchanged v1 fields, named here
because §11.2 and runner_boss_validator.gd use them), stage_speed_step 1.0, stage_speed_bonus_max 6.0, nitro_speed_cap 38.0
Stages: stage_base_distance 900.0, stage_distance_step 250.0, stage_distance_max 2000.0, stage_difficulty_step 0.25
stage_drain_distance 120.0, stage_drain_timeout 6.0, stage_clear_line_z 8.0, stage_clear_seconds 3.0
stage_clear_bonus_base 400, stage_clear_bonus_per_stage 150
stage_banner_clear_seconds 3.5 seconds after every stage_started in which no blocking hazard may reach
the player, because the stage banner is covering the lanes (§11.1)
stage_banner_reaction_seconds 1.0 seconds of travel the nearest blocking hazard is still away the moment
that window ends; must stay below stage_banner_clear_seconds
Run intro: run_intro_enabled true (tests turn it off on a duplicated config), run_intro_full_seconds 6.0,
run_intro_short_seconds 2.2, run_intro_skip_after 0.3, run_intro_skip_tail 0.4,
run_intro_full_beats ["title", "3", "2", "1", "GO!"] at run_intro_full_beat_times [1.0, 3.0, 3.8, 4.6, 5.4],
run_intro_short_beats ["READY", "GO!"] at run_intro_short_beat_times [1.0, 1.6] (v2.7, §11.15)
Boss: boss_z -14.0, boss_sway_amplitude 2.4, boss_sway_period 3.2, boss_intro_seconds 5.0 (v2.6, was 3.0),
boss_outro_seconds 1.0 (v2.6: a retreat only), boss_timeout_seconds 55.0, boss_phase_thresholds [1.0, 0.66, 0.33]
boss_hp_growth 1.35, boss_hp_scale 1.0 (test/tuning multiplier on the base HP), boss_projectile_speed_growth 1.08, boss_projectile_speed_max 34.0
boss_telegraph_growth 0.88, boss_telegraph_min 0.60, boss_attack_interval_growth 0.90, boss_attack_interval_min 0.55
boss_kill_bonus_base 600, boss_kill_bonus_per_stage 200, boss_retreat_bonus_ratio 0.35
Tools: boss_tool_spawn_z -55.0, boss_tool_interval_growth 0.93, boss_tool_interval_min 1.4
boss_tool_guarantee_seconds 4.0, boss_tool_lane_clearance 3.0, boss_shield_tool_bias 0.7, tool_bonus 20
Pursuit: pursuit_critical_seconds 5.0, pursuer_distance_epsilon 0.01
Autopilot: autopilot_telegraph_lead 0.12, autopilot_tool_greed 1.0
Signature (v2.4): boss_flaky_candidate_seconds 0.45, boss_flaky_swap_seconds 0.15 (cosmetic cadence),
boss_commit_min 0.60 (a flaky commit is at least this long before fire), boss_scatter_split_distance 10.0 (m),
boss_signature_speed_max 44.0, boss_signature_reaction_min 0.35 (the B6 floor)
Cinematic (v2.6, §11.14): boss_intro_short_seconds 2.5 (played as min(short, boss_intro_seconds)),
boss_defeat_seconds 3.6, boss_defeat_short_seconds 2.0 (min(short, full)),
boss_cinematic_skip_after 0.5, boss_cinematic_skip_tail 0.3, boss_cinematic_speed_scale 0.25,
boss_hitstop_seconds 0.12, boss_slowmo_seconds 0.8, boss_slowmo_factor 0.4
Boss base table (slot → kind, HP, projectile speed, telegraph P1/P2/P3, attack interval P1/P2/P3, tool interval):
0 flaky_test_swarm 100 / 26 / 0.90-0.80-0.72 / 1.6-1.4-1.2 / 2.6; 1 merge_conflict_hydra 140 / 28 /
0.85-0.78-0.70 / 1.5-1.3-1.1 / 2.3; 2 legacy_monolith 180 / 30 / 0.80-0.72-0.65 / 1.4-1.2-1.0 / 2.0.
Escalation per cycle c: HP × 1.35^c, projectile speed × 1.08^c (≤ 34), telegraph × 0.88^c (≥ 0.60), attack
interval × 0.90^c (≥ 0.55), tool interval × 0.93^c (≥ 1.4). Attack patterns per phase are in the systems spec §10.
SCHEMA_VERSION becomes 2. Entry fields are unchanged, so a version-1 board is migrated in place: its
entries are read normally and the next successful save rewrites the file as version 2 (players keep their v1
scores even though v2 scores also include stage and boss bonuses). The newer-schema refusal still applies to any
version > 2.
New: tests/runner_boss_validator.gd (every boss × phase × pattern × entry lane × cycle 0–4 is dodgeable by the
real autopilot planner from the telegraph; tools reachable within boss_tool_guarantee_seconds). v2.4: every beat is
resolved through boss_attacks.gd into every option it can draw, the bot hears boss_attack_planned /
boss_attack_telegraph / boss_attack_committed in the encounter's order, collisions use the motion API, B1/B3 are
measured from the commit for a flaky beat, and B6 proves that every commit, split, sweep, gap shift, debris shadow
and landing, and every branching volley, leaves from each lane a survivable answer (final lane plus at most one jump
or roll against the impact geometry) whose latest start is ≥ boss_signature_reaction_min after the event.
runner_smoke.gd
proves stage 1 → boss 1 → stage clear → stage 2 with zero projectile hits under a shortened config, and a
catch mid-fight ending the run once. runner_visual_smoke.gd gains HUD LEGIBILITY, PURSUIT ALERT,
BOSS VISUALS and TOOLS AND PROJECTILES sections and an audio census (voices ≤ 10); the full loop must cross a
stage seam, a boss and a stage-clear card.
A catch during the boss phase no longer has to throw the run away.
- Snapshot.
StageManager._enter_boss()writesgame.boss_checkpointjust beforeboss_encounter.start():stage_index, seed, score_exact, score, multiplier, best_multiplier, bonus_total, run_clock, elapsed, distance, hazards_dodged, hazards_blasted_total, stages_cleared, bosses_defeated, bosses_escaped, tools_used, boss_damage_dealt, plus the systems' Game Over talliespower_ups, hits_taken, recoveries, blocked_hits, max_speed_reached, chunks_spawned, and v2.6bosses_met(every boss kind the run has met, the one this fight opens included) andbosses_defeated_kinds(every boss kind the run has beaten before this fight), both §11.14. HP is never stored. The checkpoint is emptied onstage_clearand bystart_run(); a catch does not clear it. So it is non-empty exactly while a fight is live, or after a catch that landed in one — never after a death inrunorstage_clear. - Game Over.
ui/game_over.tscnshows Retry Boss only whileboss_checkpointis non-empty, and it takes the default focus in place of Retry (name entry still comes first on a qualifying run). Retry always restarts from zero:retry()andgo_to_menu()empty any pending checkpoint. The first ofretry(),retry_boss()andgo_to_menu()wins: once one has routed, or whileAppRouter.busy, the others return without touchingpending_boss_checkpoint(Game Over keeps focus through the route's fade). - Reload.
retry_boss()copies the checkpoint into the staticRunnerGame.pending_boss_checkpointand routes torunner_gamethrough AppRouter, likeretry()(no autoload). The nextstart_run()consumes it at once: the run takes the checkpoint'sseed, resets every system as usual (full HP, no power-ups, no pursuit), restores the facade and ScoreManager block (elapsed, distance, hazards_*, exact/score, best_multiplier, bonus_total; the live multiplier recomposes to 1.0) and the systems' tallies (EffectManagerpower_ups_collected, HitManagerhits_taken / recoveries / blocked_hits, SpeedControllermax_speed_reached, spawnerchunks_spawned), so the result reports whole-run totals, emitsrun_started/health_changed/score_changedwith those values, and callsStageManager.begin_at_boss(checkpoint)instead ofbegin(). - Lead-in.
begin_at_boss()sets the stage,run_clock(sospeed_at()returns the same speed) and the tallies, handsbosses_metto the encounter'smark_met()andbosses_defeated_kindstomark_defeated()(v2.6, so the retried boss plays the short intro, and the short defeat when the run had already beaten that kind), applies that stage's speed bonus and difficulty floor, keeps spawning off and the seam hazard-free, emitsstage_startedonce, then enters the boss through the normal_enter_boss(), which takes a fresh checkpoint, so a boss can be retried any number of times. There is no run intro on this path (v2.7, §11.15): the boss'sintro_shortcinematic is the lead-in. (v2.0–2.6 replayed the stage's turn here.) The encounter re-seedsfight_rngon everystart(index)from the run seed, its salt and the stage index alone ((seed ^ RNG_SALT) + index * RNG_STAGE_STEP), so a stage-N fight draws the same sequence whether it is reached by a normal run or by a retry. - Leaderboard. One write per run still holds: the death wrote its own entry, and the retry is a new run with a
fresh
run_id(<unix>-<seed>-boss<stage>-<msec>) whose score continues from the checkpoint. Its result carriescheckpoint_retry: true(false on every other run). - Verification.
runner_logic.gdsection 24b (checkpoint on boss entry and kept by the catch, repeatedretry_boss()reloads straight into the boss (no run intro) with stage/score/run_clockintact and full HP, plain Retry from zero, no checkpoint after a stage clear or a run-phase catch, Game Over button visibility and focus);runner_smoke.gdboss-catch phase reloads a realrunner_game.tscnfrom the checkpoint into the same fight.
A boss fight now opens and closes on a Zelda-style camera cinematic. The backend owns the clock, the camera, the hold on the player and the skip. The frontend draws the cinematic from that clock.
-
Modes and lengths. The encounter keeps two per-run kind sets, met and defeated. Both are emptied by a fresh run (
StageManager.reset()→boss_encounter.reset(), and the stage-1start()of a run that did not come from a checkpoint).mode when secondsintro_fullthe run's first meeting with this boss boss_intro_seconds(5.0)intro_shortevery later meeting (stage 4 meets stage 1's boss again), and a checkpoint retry of any boss min(boss_intro_short_seconds, boss_intro_seconds)(2.5)defeat_fullthe run's first defeat of this boss boss_defeat_seconds(3.6)defeat_shortevery later defeat of it min(boss_defeat_short_seconds, boss_defeat_seconds)(2.0)A zero-length cinematic, for example a test config with
boss_intro_seconds = 0, is not played. Noboss_cinematicand noboss_cinematic_endedare sent, and nothing holds the player. A retreat has no cinematic: it keepsplay_retreat()andboss_outro_seconds. -
Order. Intro:
boss_intro(kind, name, s)→boss_cinematic(kind, intro_*, s)→boss_spawned→boss_health_changed, all insidestart(). Thesis the same in both signals, and no attack or tool spawns until it has run down. Defeat:boss_health_changed(0, max)→boss_defeated(kind, bonus)(bonus already scored, same tick as in 2.5) →play_defeat()→boss_cinematic(kind, defeat_*, s), on one tick. The arena is emptied, and the boss phase holds forsbeforestage_cleared. Everyboss_cinematicis answered by exactly oneboss_cinematic_ended(mode). A kill landed during the intro ends the intro first. -
Skip. Only an intro can be skipped. A defeat never can: players mash jump through the fight, and a skip would routinely eat the payoff.
skip_boss_cinematic() -> boolworks only whilestate == running, the run is not paused, and the intro has played at leastboss_cinematic_skip_after(0.5 s) without already being inside its lastboss_cinematic_skip_tail(0.3 s). A skip jumps the clock toseconds − tail, so the camera return still plays. It emitsboss_cinematic_skipped(mode), andboss_cinematic_ended(mode)follows once the tail has played. With aboss_cinematic_skip_tailof 0, both are emitted by the same skip call. The intro window shrinks with the skip, so the attacks follow the tail. RunnerGame readsjump/ui_acceptpresses in_unhandled_inputwhile a skip is possible, calls the skip and marks the event handled. The controls the skip hands back re-sync held input, so the same press never also jumps. The autopilot and the smokes never skip. -
Hold. From
boss_cinematicuntil it is skipped, ends, is aborted, or the run is caught, the StageManager holds the player (stage_manager.cinematic_hold). Manual controls are off, and the corridor eases toboss_cinematic_speed_scale(0.25×) of its curve speed over 0.35 s (speed_controller.set_cinematic_scale();run_clockis frozen in the boss phase anyway). Releasing the hold restores both.RunnerGame.refresh_player_controls()is the one rule for every hand-back (backend-internal): controls are on only while running, with the bot off and no hold.set_autopilot(false)during a hold therefore leaves them off. Score is time-based and the corridor slowdown does not change it. -
Mirrors. The StageManager writes these every tick, and inside each cinematic signal before any frontend hears it:
boss_cinematic_mode(&""outside a cinematic),boss_cinematic_progress(0..1) andboss_time_factor. The StageManager does not tick while paused, so a pause freezes all three. A frontend that draws only off them cannot drift on pause or skip. -
Intro timing.
boss_visual_base.gdINTRO_POSE_AT(intro_full0.40,intro_short0.28) is the progress by which the visual has landed its final intro pose, and the title card slams at the same moment. The pose then holds frozen untilINTRO_HOLD_UNTIL(intro_full0.66,intro_short0.88), where the camera leaves the hold. The camera's push lands on the first value and its hold ends on the second, so visuals, HUD and camera share one beat. -
Visual feed. The encounter drives the mounted boss visual directly (§11.5).
play_cinematic(mode, seconds)is sent once as the set-up cue.set_cinematic_progress(mode, progress)follows with 0, then arrives on every physics tick of the cinematic, straight after a skip with the jumped progress, and with 1.0 at the end;set_time_factor()follows each time. A visual poses fromprogressalone and uses the time factor only for ambient motion, so it shares the HUD's clock exactly. -
Time factor.
boss_time_factoris 1 outside a defeat. Through a defeat it is 0.05 forboss_hitstop_seconds(0.12), thenboss_slowmo_factor(0.4) smoothstepped back to 1 overboss_slowmo_seconds(0.8), then 1. The pureboss_encounter.gdtime_factor_profile(elapsed, hitstop, slowmo, factor)defines it.Engine.time_scaleis never touched: visuals, effects, HUD and audio multiply the factor into their own delta, so the accelerated smokes stay deterministic. Gameplay timers and the cinematic clock run in real time. -
Camera. Every boss tick the StageManager calls
camera_rig.set_boss_cinematic(progress, mode, boss_position, focus_position).boss_positionis the Boss mount's global position andfocus_positionis the mount plusboss_encounter.cinematic_focus(§11.5). It sends(0, &"")at every exit. The pure staticcamera_rig.boss_shot(p, mode)returns{blend, distance, height, orbit, fov, look_bias}. The lens sits off the mount, and it aims at the focus plus a small per-shotlook_bias. That bias puts the hold near +1.6 m and the defeat close-up near +1.9 m above the mount on the shipped bosses, and a taller boss is aimed higher.blendis exactly 0 at p = 0 and p = 1, and for an unknown or empty mode, so the camera can never be left displaced. The rig mixes position, rotation and FOV with the follow pose byblend, and shake and FOV punch ride on top. Shots:intro_fullcuts in low, pushes up at the boss (FOV 70 → 52) to land with the pose atINTRO_POSE_AT, freeze-holds under the title card untilINTRO_HOLD_UNTIL, widens behind Hoppy for the hint row, then returns.intro_shortis a compressed push and return.defeat_*punches in through the hit-stop, orbits the close-up −12° → +12°, pulls back wide over Hoppy for the triumphant beat, then returns. At the default lengths each return starts where a skip lands, so a skip never pops the camera. -
Abort.
boss_encounter.abort()(a catch, a new run) closes a live cinematic withboss_cinematic_ended. The StageManager also releases the hold on catch, abort and stage clear. -
Checkpoint. §11.13
bosses_metcarries the kinds the run has met plus the boss the snapshot is taken in front of, andbosses_defeated_kindsthe kinds it had already beaten.begin_at_boss()hands them toboss_encounter.mark_met()andmark_defeated(), which the nextstart()keeps even on stage 1. A retry therefore playsintro_short, anddefeat_shortwhen the run had already beaten that kind. -
Verification.
tests/runner_boss_cinematic.gdcovers: full intro on stage 1 and short on stage 4, short after a checkpoint retry (and a short defeat for a kind the run had already beaten), skip ignored before 0.5 s and accepted after with no jump, skip always refused during a defeat, pause freezing progress, the defeat holdingstage_clearfor its full or short length,boss_shotblend 0 at both ends, thetime_factorprofile, thecinematic_focusdefault and the intro timing constants, and an abort mid-cinematic restoring controls and speed.
Every run now opens on a short in-engine cinematic. The backend owns the clock, the camera, the hold on the player, the beats and the skip; the frontend draws the title card and countdown from that clock.
- When.
RunnerGame.pending_intro_mode(static, likepending_boss_checkpoint) picks the intro the nextstart_run()opens with and is consumed there (back to&"full"). A fresh run from the menu gets full (run_intro_full_seconds6.0);retry()sets short (run_intro_short_seconds2.2), andgo_to_menu()sets full again;&""opens the next run with no intro (capture tools use it).start_run()hands the mode toStageManager.begin(intro_mode), which defaults to&""(no intro) when called directly, andrun_intro_enabledfalse skips every intro. A boss-checkpoint retry never plays a run intro, whatever the pending mode:begin_at_boss(checkpoint)goes straight tostage_startedand the boss, whoseintro_shortcinematic is the lead-in, so the player is never handed control at GO only to lose it to the boss intro. - Phase.
introcomes before stage 1'srun; stage 1'sstage_startedgoes out when the intro ends, as the run opens. The spawner places nothing in it. It is not run time:run_clock, the facade'selapsedanddistanceand the ScoreManager's base-rate accrual all stay at zero through it and start at the hand-over, so the HUD clock reads 00:00 and the score 0 at GO. - Beats.
run_intro_beat(index, label)goes out as the intro's clock reaches each time inrun_intro_*_beat_times(fulltitle1.0,33.0,23.8,14.6,GO!5.4; shortREADY1.0,GO!1.6), always in index order. Thetitlebeat is the title card's cue and plays Hoppy'svictoryclip throughrunner_player.gd→visual.celebrate(). - Hold. From
run_intrountil the last beat (GO) the StageManager holdscinematic_hold: manual controls are off (autopilot included, throughrefresh_player_controls()) and the corridor eases toboss_cinematic_speed_scale. GO hands both back before it is sent. The camera and the mirrors keep following the clock to the end. - Skip. Jump or
ui_acceptskips oncerun_intro_skip_after(0.3 s) has played, until the GO beat has gone out or the lastrun_intro_skip_tail(0.4 s) begins, whichever is first, while running and unpaused (can_skip_run_intro()); the press is consumed and never jumps. From GO on, controls are live and a jump is a jump. A skip jumps the clock to the tail, gives control back at once, sendsrun_intro_skipped(), then the GO beat if it had not gone out yet; the beats it jumped over are never sent. The tail still plays, so the camera lands. The autopilot never skips. - Camera. Every intro tick the StageManager calls
camera_rig.set_run_intro(progress, mode, focus)with Hoppy's global position asfocus, and(0, &"", Vector3.ZERO)at every exit. The pure staticcamera_rig.run_intro_shot(p, mode)returns{blend, distance, height, orbit, fov, look_bias}from the frontend'sres://scenes/runner/data/run_intro_shots.gdconst SHOTS := {&"full": {cut, ret, keys}, &"short": {...}}(rows[p, distance, height, orbit, fov, look_bias], smoothstepped likeBOSS_SHOTS), read once throughResourceLoader.exists+load()and never preloaded, with a built-in fallback per missing mode. The lens sitsdistancem behindfocusalong +Z, rotatedorbit° about it andheightm up, aimed atfocus+look_biasm; the rest pose is[8.0, 4.0, 0.0, 60.0, 2.5].cut0 is a hard cut on frame one (the rig resets the camera's physics interpolation on it);blendramps out over[ret, 1]and is exactly 0 at p = 1. Keepretat or below the skip landing (0.933 full, 0.818 short) so a skip never pops the camera. - HUD.
runner_hud.gdhides its widgets and GET READY whilephaseisintro, and opens GET READY with the run when the intro hands over torun.ui/run_intro.tscnis an optional HUD mount (§11.8). - Abort. A catch or a new run closes a live intro with
run_intro_ended()and releases the hold. - Verification.
tests/runner_run_intro.gdcovers: full on a fresh run and short afterretry(), no run intro on a checkpoint retry (stage_started, then the boss), the beats and their order and times, controls held until GO and back on it, the skip window (refused before 0.3 s, from GO and in the tail; no jump on a skip press; a jump on GO is a jump),elapsed,distanceand score at zero at the hand-over, the spawner's placement gates through the intro and the clear road after it, the GO beat after a skip, pause freezing the clock,run_intro_endedexactly once on every exit path,run_intro_shotblend at both ends and the rest pose, andrun_intro_enabledoff going straight torun.