Player Ship Tilt (banking) Implementation Plan
Player Ship Tilt (banking) Implementation Plan
Section titled “Player Ship Tilt (banking) Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the player ship visibly bank/roll into turns (like arcade bullet-hell games), via two comparable rendering mechanisms selectable through a dev-only toggle, so Chris can pick a winner on real hardware before either ships as the default.
Architecture: A shared, pure, headless-testable tilt-driver (ShipTilt) turns the ship’s
existing per-frame facing-angle rate-of-change into a smoothed, clamped signed tilt value.
PlayerRenderer consumes that value through one of two mechanisms — a per-sprite canvas_item
shader warp, or a snap-to-nearest baked-angle sprite flipbook (reusing the already-proven-safe
offline Ship3DRenderer bake pipeline) — selected via a new dev-only cycle row in
DebugSettingsPanel. Everything is render-side only; /sim and determinism are untouched.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 for tests.
Global Constraints
Section titled “Global Constraints”/sim/*stays pure logic (extends RefCounted, no Node/Engine/Input/Time/OS/File/JSON APIs) — this feature must not touch/simat all; it is 100% render-side.- One-way data flow: render/UI only ever READS sim state. Nothing in this plan writes to
/sim. - Determinism baseline to re-verify unchanged after EVERY task:
snapshot_string().hash() = 4217109746,state_checksum() = 2666143677— read the literal assertions intests/test_determinism_checksum.gd/tests/test_determinism_crystals.gdbefore each check; do not trust this line if it ever looks stale (it has drifted from reality before elsewhere in this project). - After adding any new file with a
class_name, rungodot --headless --path . --importbefore running tests (stale class cache silently drops that file’s tests). - Boot-check after every task:
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"must be empty. Never wrap withtimeout(not on macOS PATH here — usegtimeoutif needed, but 90 frames headless is fast enough that a wrapper isn’t necessary). - Full suite + count guard after every task:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitthenbash scripts/check-test-count.sh. Trust the count, not just “all green.” - GUT assertion names:
assert_lte/assert_gte(NOTassert_le/assert_ge). - Commit once per task, one chunk = one commit, message ending with
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>— only when asked to commit or inside an autonomous loop (default to asking first, per this session’s standing instructions). - Scope is the player ship only, and only the default hull,
cobalt(ShipBonuses.DEFAULT_SHIP) gets baked tilt-frame art in this plan — the other 6 real hulls (manta, aurum, prism, amethyst, fuchsia, obsidian) are explicitly out of scope for baked frames (their original bake recipes aren’t reproducibly documented in this repo; onlycobalt’s tier-0 procedural recipe is known- good viaShip3DRenderer.set_tier(0)). The shader-warp mechanism has no such limitation — it works on whichever hull is currently baked as_baked.texture, for any hull. - No live
SubViewport-3D at runtime, anywhere in this plan — that path is proven broken on the Apple TV’s Mobile/Metal renderer (git95bfdfd).Ship3DRendererin this plan is only ever used from the offline, windowedtools/ship_previewtool, never instantiated in-game.
File Structure
Section titled “File Structure”| File | Responsibility |
|---|---|
render/ship_tilt.gd (new) |
Pure tilt-driver: turn-rate → smoothed, clamped signed tilt value. No Node/rendering. |
tests/test_ship_tilt.gd (new) |
GUT tests for ShipTilt.advance(). |
main.gd (modify) |
Compute tilt each frame from the player’s facing history; forward it to player_renderer.set_tilt(). |
render/player_renderer.gd (modify) |
TiltMode enum, set_tilt_mode()/set_tilt(), the shader-warp material, and the baked-flipbook frame selection. |
tests/test_player_renderer.gd (modify) |
New tests for tilt-mode switching and both mechanisms’ behavior. |
ui/debug_settings_panel.gd (modify) |
New “Ship Tilt” cycle row (Off / Shader Warp / Baked Flipbook), mirroring the existing background-variant cycle row. |
tests/test_debug_settings_panel.gd (modify) |
New tests for the tilt-mode row + a set_tilt_mode/tilt_mode addition to the existing _StubToggleNode. |
render/ship3d_renderer.gd (modify) |
New set_roll(rad) method (rotates the 3D pivot around its roll/bank axis) for the offline bake tool to use. |
tools/ship_preview/preview.gd (modify) |
New bake_tilt SHIP_VARIANT, rendering cobalt’s tier-0 hull at each angle in PlayerRenderer.TILT_ANGLES_DEG and saving to render/ship_sprites/. |
render/ship_sprites/ship3d_cobalt_tilt_neg30.png etc. (new, 4 files) |
Baked tilt-angle art for the flipbook mechanism, generated by running the tool (not hand-authored). |
Task 1: ShipTilt pure tilt-driver
Section titled “Task 1: ShipTilt pure tilt-driver”Files:
- Create:
render/ship_tilt.gd - Test:
tests/test_ship_tilt.gd
Interfaces:
-
Produces:
class_name ShipTilt extends RefCounted,const MAX_TILT: float(radians, = 30°),static func advance(prev_tilt: float, facing: float, prev_facing: float, dt: float) -> float. -
Step 1: Write the failing tests
extends GutTest
func test_zero_turn_rate_relaxes_toward_zero() -> void: # Facing constant across frames (no turn) — an existing nonzero tilt must decay toward 0. var tilt := 0.4 for _i in range(60): tilt = ShipTilt.advance(tilt, 0.0, 0.0, 1.0 / 60.0) assert_almost_eq(tilt, 0.0, 0.01, "tilt relaxes to level flight when facing stops changing")
func test_turning_right_produces_positive_tilt() -> void: # Facing increases each frame (turning one way) -> tilt should end up positive. var tilt := 0.0 var facing := 0.0 for _i in range(30): var prev := facing facing += 0.05 tilt = ShipTilt.advance(tilt, facing, prev, 1.0 / 60.0) assert_gt(tilt, 0.0, "a sustained turn in the positive direction produces positive tilt")
func test_turning_left_produces_negative_tilt() -> void: var tilt := 0.0 var facing := 0.0 for _i in range(30): var prev := facing facing -= 0.05 tilt = ShipTilt.advance(tilt, facing, prev, 1.0 / 60.0) assert_lt(tilt, 0.0, "a sustained turn in the negative direction produces negative tilt")
func test_tilt_never_exceeds_max_even_under_an_extreme_turn_rate() -> void: var tilt := 0.0 var facing := 0.0 for _i in range(30): var prev := facing facing += 3.0 # an absurdly large per-frame facing jump tilt = ShipTilt.advance(tilt, facing, prev, 1.0 / 60.0) assert_lte(tilt, ShipTilt.MAX_TILT, "tilt is clamped to the max even under an extreme turn rate") assert_gte(tilt, -ShipTilt.MAX_TILT, "tilt clamp is symmetric")
func test_zero_dt_is_a_no_op() -> void: var tilt := ShipTilt.advance(0.2, 1.5, 0.0, 0.0) assert_eq(tilt, 0.2, "a zero (or negative) dt must not change the tilt — avoids a div-by-zero turn-rate")
func test_single_frame_jump_does_not_snap_straight_to_target() -> void: # A single frame of a sharp turn should only move PART way toward the clamped target — # the smoothing filter, not an instant snap — so the bank reads as motion, not a pop. var tilt := ShipTilt.advance(0.0, 1.0, 0.0, 1.0 / 60.0) assert_gt(tilt, 0.0, "tilt moves toward the turn direction") assert_lt(tilt, ShipTilt.MAX_TILT, "a single frame does not reach the full clamp")- Step 2: Run the tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_tilt -gexit
Expected: FAIL — Could not find type "ShipTilt" (the class doesn’t exist yet).
- Step 3: Write the implementation
class_name ShipTiltextends RefCounted
# Pure render-side helper: turns the ship's existing yaw-rotation rate of change into a smoothed,# clamped signed "bank" value — banks into a sharp turn, levels out flying straight. Deliberately# NOT driven by raw move-stick position: the ship already fully reorients its whole body to face# its movement direction (main.gd's _player_facing), unlike a fixed-heading strafer, so turn-rate# banking is what actually matches how this ship already moves. Consumed by both tilt rendering# mechanisms in PlayerRenderer so switching between them via the dev toggle compares rendering# only, never the underlying feel. No Node/Engine APIs — pure math, headless-testable.
const MAX_TILT := 0.5236 # 30 degrees in radians — matches PlayerRenderer.TILT_ANGLES_DEG's extremesconst RATE_GAIN := 2.2 # TUNABLE: how strongly turn-rate maps to the tilt targetconst SMOOTH_RATE := 10.0 # TUNABLE: 1/s exponential-smoothing speed toward the target tilt
static func advance(prev_tilt: float, facing: float, prev_facing: float, dt: float) -> float: if dt <= 0.0: return prev_tilt var turn_rate := angle_difference(prev_facing, facing) / dt # signed rad/s var target := clampf(turn_rate * RATE_GAIN, -MAX_TILT, MAX_TILT) var k := clampf(SMOOTH_RATE * dt, 0.0, 1.0) return lerpf(prev_tilt, target, k)-
Step 4: Run
godot --headless --path . --import(newclass_namefile) -
Step 5: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_ship_tilt -gexit
Expected: PASS (6/6).
- Step 6: Boot-check
Run: godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
Expected: empty output.
- Step 7: Full suite + count guard
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.shExpected: all green, count matches tests/test_*.gd file count (one more script than before).
- Step 8: Determinism re-check
Read the literal assertions in tests/test_determinism_checksum.gd and
tests/test_determinism_crystals.gd (currently 4217109746 / 2666143677) and confirm both
determinism test files still pass in Step 7’s run — this task touches no /sim file, so they
must be byte-identical.
- Step 9: Commit
git add render/ship_tilt.gd tests/test_ship_tilt.gdgit commit -m "$(cat <<'EOF'feat(render): ShipTilt pure turn-rate banking driver
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"Task 2: Wire tilt computation into main.gd + a no-op-safe PlayerRenderer seam
Section titled “Task 2: Wire tilt computation into main.gd + a no-op-safe PlayerRenderer seam”Files:
- Modify:
main.gd(nearmain.gd:1155-1167, the existing per-frame player facing/rotation block) - Modify:
render/player_renderer.gd - Test:
tests/test_player_renderer.gd
Interfaces:
- Consumes:
ShipTilt.advance(prev_tilt, facing, prev_facing, dt) -> float(Task 1). - Produces:
PlayerRenderer.TiltModeenum (OFF = 0, SHADER_WARP = 1, BAKED_FLIPBOOK = 2),PlayerRenderer.tilt_mode: int(defaultTiltMode.OFF),PlayerRenderer.set_tilt_mode(mode: int) -> void,PlayerRenderer.set_tilt(rad: float) -> void(no-op whentilt_mode == TiltMode.OFF).
This task establishes the full data path (main.gd computes tilt every frame → calls
player_renderer.set_tilt()) with both mechanisms still unimplemented no-ops, so the seam can be
tested and committed independently before either mechanism’s visual code lands.
- Step 1: Write the failing tests
Add to tests/test_player_renderer.gd (uses the existing _pr() helper already in that file):
func test_tilt_mode_defaults_to_off() -> void: var p := _pr() assert_eq(p.tilt_mode, PlayerRenderer.TiltMode.OFF, "tilt starts OFF")
func test_set_tilt_mode_updates_the_mode() -> void: var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.SHADER_WARP) assert_eq(p.tilt_mode, PlayerRenderer.TiltMode.SHADER_WARP, "set_tilt_mode stores the mode")
func test_set_tilt_is_safe_to_call_in_every_mode_without_crashing() -> void: var p := _pr() for mode in [PlayerRenderer.TiltMode.OFF, PlayerRenderer.TiltMode.SHADER_WARP, PlayerRenderer.TiltMode.BAKED_FLIPBOOK]: p.set_tilt_mode(mode) p.set_tilt(0.3) # must not crash in any mode, even before either mechanism is implemented- Step 2: Run to verify failure
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: FAIL — Identifier "TiltMode" not declared / tilt_mode not found.
- Step 3: Add the seam to
render/player_renderer.gd
Add near the top of the class (after the existing const USE_BAKED_HULL block, before var low_detail):
# ── Ship tilt (banking) — two comparable mechanisms behind a dev toggle (DebugSettingsPanel) ──enum TiltMode { OFF, SHADER_WARP, BAKED_FLIPBOOK }# Angles baked for the flipbook mechanism (cobalt only — see the plan's Global Constraints for# why other hulls are out of scope). 0 degrees is deliberately absent: it reuses the already-# loaded base hull texture instead of a redundant duplicate bake.const TILT_ANGLES_DEG: Array[int] = [-30, -15, 15, 30]
var tilt_mode: int = TiltMode.OFFAdd near the bottom of the class, after func set_facing:
func set_tilt_mode(mode: int) -> void: tilt_mode = mode
# Called every render frame with the current ShipTilt value (radians). No-op in OFF mode; the# other two modes are implemented in later tasks (Task 3: SHADER_WARP, Task 6: BAKED_FLIPBOOK).func set_tilt(_rad: float) -> void: pass- Step 4: Wire
main.gd’s per-frame block
Modify the block at main.gd:1155-1167. Add two new fields near the existing var _player_facing: float = 0.0
(around main.gd:107):
var _ship_tilt: float = 0.0var _prev_facing_for_tilt: float = 0.0Then extend the existing block (the line numbers below are those from the file as read for this plan; re-locate by matching the surrounding code if line numbers have since shifted):
var _move_delta: Vector2 = sim.player.pos - _last_player_pos if _move_delta.length_squared() > 1.0: _player_facing = _move_delta.angle() + PI / 2.0 player_node.rotation = lerp_angle(player_node.rotation, _player_facing, 0.25) _ship_tilt = ShipTilt.advance(_ship_tilt, player_node.rotation, _prev_facing_for_tilt, delta) _prev_facing_for_tilt = player_node.rotation player_renderer.set_facing(player_node.rotation) # world-fixed key light on the lit hull player_renderer.set_tilt(_ship_tilt)(Tilt is derived from player_node.rotation — the already-smoothed on-screen rotation — not the
raw _player_facing target, since the target can jump in a single step when movement direction
changes; the smoothed rotation is what’s actually animating frame to frame.)
-
Step 5: Run
godot --headless --path . --import(no newclass_namefiles this task, but run it anyway — harmless and matches the ritual). -
Step 6: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: PASS.
- Step 7: Boot-check
Run: godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
Expected: empty output. (This is the step that would catch a typo in the main.gd edit — a bad
:= or an out-of-scope variable reference fails the whole script’s compile silently otherwise.)
- Step 8: Full suite + count guard
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitbash scripts/check-test-count.sh- Step 9: Determinism re-check
Confirm tests/test_determinism_checksum.gd / tests/test_determinism_crystals.gd still assert
4217109746 / 2666143677 and pass. This task only adds render-side state (_ship_tilt,
_prev_facing_for_tilt on main.gd, which is not part of /sim) — must be unchanged.
- Step 10: Commit
git add main.gd render/player_renderer.gd tests/test_player_renderer.gdgit commit -m "$(cat <<'EOF'feat(render): wire ShipTilt into main.gd + a PlayerRenderer tilt-mode seam
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"Task 3: Mechanism A — shader-warp tilt
Section titled “Task 3: Mechanism A — shader-warp tilt”Files:
- Modify:
render/player_renderer.gd - Test:
tests/test_player_renderer.gd
Interfaces:
-
Consumes:
TiltMode.SHADER_WARP(Task 2),_baked: Sprite2D(existing field, the baked-hull sprite). -
Produces:
PlayerRenderer._tilt_shader_mat: ShaderMaterial(readable by tests), fully workingSHADER_WARPbranch ofset_tilt_mode()/set_tilt(). -
Step 1: Write the failing tests
Add to tests/test_player_renderer.gd:
func test_shader_warp_mode_applies_a_shader_material_to_the_baked_hull() -> void: if not PlayerRenderer.USE_BAKED_HULL: pass_test("shader-warp tilt only applies to the baked hull sprite") return var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.SHADER_WARP) assert_true(p._baked.material is ShaderMaterial, "baked hull gets a ShaderMaterial in shader-warp mode")
func test_shader_warp_tilt_value_reaches_the_shader_uniform() -> void: if not PlayerRenderer.USE_BAKED_HULL: pass_test("shader-warp tilt only applies to the baked hull sprite") return var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.SHADER_WARP) p.set_tilt(0.3) var mat := p._baked.material as ShaderMaterial assert_almost_eq(float(mat.get_shader_parameter("tilt")), 0.3 / ShipTilt.MAX_TILT, 0.001, "the shader's normalized tilt uniform tracks set_tilt(), scaled by ShipTilt.MAX_TILT")
func test_switching_back_to_off_removes_the_shader_material() -> void: if not PlayerRenderer.USE_BAKED_HULL: pass_test("shader-warp tilt only applies to the baked hull sprite") return var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.SHADER_WARP) p.set_tilt_mode(PlayerRenderer.TiltMode.OFF) assert_null(p._baked.material, "OFF mode clears the tilt material back to the plain baked sprite")- Step 2: Run to verify failure
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: FAIL — _tilt_shader_mat/behavior not implemented, _baked.material stays null in
SHADER_WARP mode.
- Step 3: Implement the shader-warp mechanism
Add near the existing _HULL_SHADER constant in render/player_renderer.gd:
# Mechanism A (dev-toggle comparison, see docs/superpowers/specs/2026-07-06-player-ship-tilt-design.md):# a perspective-style UV warp on the baked hull sprite, driven by a normalized -1..1 tilt uniform.# Foreshortens the receding edge rather than a flat shear, so it reads as "rotating away" instead# of "getting squished." TUNABLE: WARP_STRENGTH below controls how pronounced the effect is.const _TILT_SHADER := "shader_type canvas_item;uniform float tilt : hint_range(-1.0, 1.0) = 0.0;uniform float warp_strength : hint_range(0.0, 1.0) = 0.6;void fragment() { vec2 uv = UV; float cx = uv.x - 0.5; float warp = tilt * warp_strength; float persp = 1.0 - warp * cx; float new_cx = cx / max(persp, 0.2); uv.x = clamp(new_cx + 0.5, 0.0, 1.0); uv.y = 0.5 + (uv.y - 0.5) * (1.0 - 0.15 * abs(warp)); vec4 tex = texture(TEXTURE, uv); COLOR = tex;}"Add a field near the other _lit_mat/_baked vars:
var _tilt_shader_mat: ShaderMaterialReplace the Task 2 stub methods with:
func set_tilt_mode(mode: int) -> void: tilt_mode = mode _apply_tilt_mode()
func _apply_tilt_mode() -> void: if _baked == null: return if tilt_mode == TiltMode.SHADER_WARP: if _tilt_shader_mat == null: _tilt_shader_mat = ShaderMaterial.new() var sh := Shader.new() sh.code = _TILT_SHADER _tilt_shader_mat.shader = sh _baked.material = _tilt_shader_mat else: _baked.material = null if tilt_mode == TiltMode.BAKED_FLIPBOOK: _load_tilt_frames() # implemented in Task 6; safe no-op until then
# Called every render frame with the current ShipTilt value (radians).func set_tilt(rad: float) -> void: match tilt_mode: TiltMode.SHADER_WARP: if _tilt_shader_mat != null: _tilt_shader_mat.set_shader_parameter("tilt", clampf(rad / ShipTilt.MAX_TILT, -1.0, 1.0)) TiltMode.BAKED_FLIPBOOK: _apply_nearest_tilt_frame(rad) # implemented in Task 6; safe no-op until then _: passAdd temporary no-op stubs so this task compiles standalone (Task 6 will replace them with real bodies):
func _load_tilt_frames() -> void: pass
func _apply_nearest_tilt_frame(_rad: float) -> void: pass-
Step 4: Run
godot --headless --path . --import -
Step 5: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: PASS.
- Step 6: Boot-check
Run: godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
Expected: empty. (Per this project’s own convention, the headless dummy driver still compiles and
validates canvas_item shader GLSL on material assignment, so a shader syntax error would surface
here even though nothing is actually rendered.)
-
Step 7: Full suite + count guard, Step 8: Determinism re-check — same commands and baseline as Task 1/2. Expected unchanged (
4217109746/2666143677). -
Step 9: Commit
git add render/player_renderer.gd tests/test_player_renderer.gdgit commit -m "$(cat <<'EOF'feat(render): shader-warp ship tilt mechanism
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"Task 4: Dev-toggle cycle row in DebugSettingsPanel
Section titled “Task 4: Dev-toggle cycle row in DebugSettingsPanel”Files:
- Modify:
ui/debug_settings_panel.gd - Modify:
tests/test_debug_settings_panel.gd
Interfaces:
-
Consumes:
PlayerRenderer.TiltMode(Task 2/3),PlayerRenderer.set_tilt_mode()(Task 2). -
Produces:
DebugSettingsPanel.apply_tilt_mode(mode: int, qm: QualityManager) -> void(static, mirrorsapply_background_variant), a new “Ship Tilt” row in_build_rows(). -
Step 1: Write the failing tests
Add to tests/test_debug_settings_panel.gd. First extend the existing _StubToggleNode (used for
qm.player_visual in _bound_qm()) with the two members the real PlayerRenderer exposes:
# Add inside class _StubToggleNode, alongside its existing fields: var tilt_mode := 0 func set_tilt_mode(v: int) -> void: tilt_mode = vThen add new test functions:
func test_open_panel_builds_a_ship_tilt_row() -> void: var qm := _bound_qm() var p := DebugSettingsPanel.new() add_child_autofree(p) p.open_panel(qm) await get_tree().process_frame assert_not_null(_row_button(p, "Ship Tilt:"), "a Ship Tilt row exists")
func test_ship_tilt_row_starts_off() -> void: var qm := _bound_qm() var p := DebugSettingsPanel.new() add_child_autofree(p) p.open_panel(qm) await get_tree().process_frame assert_eq(_row_button(p, "Ship Tilt:").text, "Ship Tilt: Off")
func test_cycling_ship_tilt_row_advances_through_all_three_modes() -> void: var qm := _bound_qm() var p := DebugSettingsPanel.new() add_child_autofree(p) p.open_panel(qm) await get_tree().process_frame var btn := _row_button(p, "Ship Tilt:") assert_eq(btn.text, "Ship Tilt: Off") btn.emit_signal("pressed") assert_eq(btn.text, "Ship Tilt: Shader Warp") btn.emit_signal("pressed") assert_eq(btn.text, "Ship Tilt: Baked Flipbook") btn.emit_signal("pressed") assert_eq(btn.text, "Ship Tilt: Off", "wraps back to Off")
func test_cycling_ship_tilt_row_calls_set_tilt_mode_on_player_visual() -> void: var qm := _bound_qm() var p := DebugSettingsPanel.new() add_child_autofree(p) p.open_panel(qm) await get_tree().process_frame _row_button(p, "Ship Tilt:").emit_signal("pressed") assert_eq((qm.player_visual as _StubToggleNode).tilt_mode, PlayerRenderer.TiltMode.SHADER_WARP)
func test_apply_tilt_mode_is_null_safe() -> void: var qm := QualityManager.new() autofree(qm) DebugSettingsPanel.apply_tilt_mode(PlayerRenderer.TiltMode.SHADER_WARP, qm) # nothing bound — must not crash
func test_ship_tilt_choice_survives_a_real_run_rebind() -> void: # Mirrors test_background_row_choice_survives_a_real_run_rebind: a dev's explicit tilt-mode # choice must be re-applied onto a fresh run's newly-bound player_visual node. var qm := _bound_qm() var p := DebugSettingsPanel.new() add_child_autofree(p) p.open_panel(qm) await get_tree().process_frame _row_button(p, "Ship Tilt:").emit_signal("pressed") # -> Shader Warp var fresh_player := _StubToggleNode.new() autofree(fresh_player) qm.player_visual = fresh_player p.open_panel(qm) await get_tree().process_frame assert_eq(fresh_player.tilt_mode, PlayerRenderer.TiltMode.SHADER_WARP, "the dev's earlier choice is re-applied onto the fresh run's new player_visual node")- Step 2: Run to verify failure
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_debug_settings_panel -gexit
Expected: FAIL — no “Ship Tilt” row exists yet, apply_tilt_mode not declared.
- Step 3: Implement the row in
ui/debug_settings_panel.gd
Add near the existing BG_VARIANT_NAMES const:
const TILT_MODE_NAMES := ["Off", "Shader Warp", "Baked Flipbook"]Add near _bg_variant_idx/_bg_variant_touched:
var _tilt_mode_idx := 0Add a static dispatch function near apply_background_variant:
# Dev ship-tilt-mechanism switcher. Unlike apply_background_variant, set_tilt_mode is idempotent# (no randomization side effect), so it can always be safely re-pushed on every open_panel() call —# no "touched" guard needed.static func apply_tilt_mode(mode: int, qm: QualityManager) -> void: if is_instance_valid(qm.player_visual) and qm.player_visual.has_method("set_tilt_mode"): qm.player_visual.set_tilt_mode(mode)Add a cycle row builder near _bg_variant_row:
func _tilt_mode_row() -> Dictionary: return { "label": "Ship Tilt", "is_graphics": false, "is_cycle": true, "get_text": func() -> String: return "Ship Tilt: %s" % TILT_MODE_NAMES[_tilt_mode_idx], "advance": func() -> void: _tilt_mode_idx = (_tilt_mode_idx + 1) % TILT_MODE_NAMES.size() apply_tilt_mode(_tilt_mode_idx, _qm), }Add the row to _build_rows(), right after the existing _rows.append(_bg_variant_row()) line:
_rows.append(_tilt_mode_row())Add the re-push in open_panel(), right after the existing
for effect in _graphics_state.keys(): apply_graphics_toggle(effect, _graphics_state[effect], _qm)
loop (still inside the if _qm != null: block):
apply_tilt_mode(_tilt_mode_idx, _qm)-
Step 4: Run
godot --headless --path . --import -
Step 5: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_debug_settings_panel -gexit
Expected: PASS.
-
Step 6: Boot-check — same command as prior tasks, expect empty output.
-
Step 7: Full suite + count guard, Step 8: Determinism re-check — same as prior tasks, expect unchanged baseline.
-
Step 9: Commit
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gdgit commit -m "$(cat <<'EOF'feat(ui): dev-only Ship Tilt comparison row in Debug Settings
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"Task 5: Ship3DRenderer.set_roll() + offline bake_tilt variant
Section titled “Task 5: Ship3DRenderer.set_roll() + offline bake_tilt variant”Files:
- Modify:
render/ship3d_renderer.gd - Modify:
tools/ship_preview/preview.gd - Create (via running the tool, not by hand):
render/ship_sprites/ship3d_cobalt_tilt_neg30.png,ship3d_cobalt_tilt_neg15.png,ship3d_cobalt_tilt_pos15.png,ship3d_cobalt_tilt_pos30.png(plus each PNG’s Godot-generated.importsidecar).
Interfaces:
- Produces:
Ship3DRenderer.set_roll(rad: float) -> void. - Consumes:
PlayerRenderer.TILT_ANGLES_DEG(Task 2) as the angle list to bake.
This task is a tool/content change, not gameplay code — Ship3DRenderer has no existing GUT tests
(it needs a real GPU/SubViewport context that doesn’t run meaningfully headless, same reason
tools/ship_preview itself has never been unit tested), so this task is verified by running the
windowed tool and checking the output files exist, not by GUT.
- Step 1: Add
set_roll()torender/ship3d_renderer.gd
Add right after the existing func set_facing(rad: float) -> void: method:
# Roll/bank around the ship's forward (-Z) axis — the axis a real tilt rotates around. Kept# separate from set_facing (which only ever writes rotation.y) so the two compose: bake-tilt# calls both set_facing(0.0) and set_roll(angle) before capturing each frame.func set_roll(rad: float) -> void: if _pivot != null: _pivot.rotation.z = rad- Step 2: Add the
bake_tiltvariant totools/ship_preview/preview.gd
Modify the _ready() dispatch to add a new branch, right after the existing
if variant == "bake": await _do_bake(); return check:
if variant == "bake_tilt": await _do_bake_tilt() returnAdd the new bake function right after the existing _do_bake():
# Bake cobalt's tier-0 hull at each non-zero angle in PlayerRenderer.TILT_ANGLES_DEG, for the# baked-flipbook tilt mechanism (see docs/superpowers/plans/2026-07-06-player-ship-tilt.md, Task 5).# 0 degrees is intentionally NOT baked here — PlayerRenderer reuses the already-committed# ship3d_cobalt.png for that frame instead of a redundant duplicate.func _do_bake_tilt() -> void: Ship3DRenderer.vp_size_override = 512 Ship3DRenderer.topdown_override = true get_window().size = Vector2i(560, 560) DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BAKE_DIR)) var ship := Ship3DRenderer.new() ship.position = Vector2(280, 280) add_child(ship) ship.set_tier(0) # cobalt is baked from the tier-0 (cyan) procedural hull ship.set_facing(0.0) for deg in PlayerRenderer.TILT_ANGLES_DEG: ship.set_roll(deg_to_rad(float(deg))) for _i in range(16): await get_tree().process_frame await RenderingServer.frame_post_draw var img: Image = ship.grab_image() var suffix := "neg%d" % -deg if deg < 0 else "pos%d" % deg var p := "%s/ship3d_cobalt_tilt_%s.png" % [BAKE_DIR, suffix] img.save_png(p) print("BAKED %s (%dx%d)" % [ProjectSettings.globalize_path(p), img.get_width(), img.get_height()]) remove_child(ship) ship.queue_free() get_tree().quit()-
Step 3: Run
godot --headless --path . --import(no newclass_name, but keep the ritual). -
Step 4: Boot-check
Run: godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
Expected: empty.
- Step 5: Run the bake tool (windowed — this cannot run headless, matching every other
ship_previewvariant)
Run: SHIP_VARIANT=bake_tilt godot --path . res://tools/ship_preview/preview.tscn --rendering-method forward_plus
Expected: 4 lines of BAKED .../render/ship_sprites/ship3d_cobalt_tilt_<suffix>.png (512x512)
printed, then the window closes on its own (get_tree().quit()).
- Step 6: Verify the files exist and look right
ls -la render/ship_sprites/ship3d_cobalt_tilt_*.pngExpected: 4 files (neg30, neg15, pos15, pos30), each a non-trivial size (tens of KB, not
0 bytes). Open a couple in Preview.app (or qlmanage -p) to sanity-check they show a visibly
rolled cobalt hull, not a blank/black frame — this is the same manual “does the SubViewport
capture actually contain the ship” check the original hull bake needed.
- Step 7: Let Godot generate
.importsidecars
Run: godot --headless --path . --import
Expected: 4 new render/ship_sprites/ship3d_cobalt_tilt_*.png.import files appear.
-
Step 8: Full suite + count guard, determinism re-check — same as prior tasks (this task touches no test files, so the count should be unchanged from Task 4; still run the full suite to confirm nothing else broke). Expected baseline unchanged (
4217109746/2666143677). -
Step 9: Commit
git add render/ship3d_renderer.gd tools/ship_preview/preview.gd render/ship_sprites/ship3d_cobalt_tilt_*.png render/ship_sprites/ship3d_cobalt_tilt_*.png.importgit commit -m "$(cat <<'EOF'feat(tools): bake cobalt tilt-angle frames for the flipbook tilt mechanism
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"Addendum (added after Task 5 landed, before Task 6 started)
Section titled “Addendum (added after Task 5 landed, before Task 6 started)”Task 5’s own reviewer surfaced a real gap: the plan assumed ship3d_cobalt.png (the 0°/base
frame) came from the same procedural Ship3DRenderer.set_tier(0) recipe as the new tilt
frames. It doesn’t — git log shows it’s actually AI-generated concept art (commit 5826b96,
216KB) added well after the procedural bake pipeline existed, vs. the new procedural tilt
frames (~33-38KB each). Mixing them in one flipbook would visibly “pop” between a detailed ship
at 0° and a plain wedge at every other angle.
Chris’s call (recommended and chosen): re-bake ALL 5 angles procedurally, including 0°, so the whole flipbook is visually consistent for this comparison spike — rather than mixing art styles. This supersedes the original Task 5/6 text below in two small ways, both already applied to Task 5’s shipped code in a follow-up commit before Task 6 began:
PlayerRenderer.TILT_ANGLES_DEGis[-30, -15, 0, 15, 30](5 entries, 0 included) — not the 4-entry[-30, -15, 15, 30]shown in Task 2’s original listing above.tools/ship_preview/preview.gd’s_do_bake_tilt()suffix logic is three-way:deg < 0 → "neg%d" % -deg,deg > 0 → "pos%d" % deg,deg == 0 → "0"— producing a 5th file,render/ship_sprites/ship3d_cobalt_tilt_0.png, alongside the original 4.
Task 6 (below) is written against this corrected 5-entry design — it does NOT special-case 0°
or fall back to the original ship3d_cobalt.png; every angle, including level flight, shows a
baked tilt frame. (If Chris later sources better, style-matched tilt art from Recraft or
similar, swapping the 5 PNG files at their existing paths is enough — PlayerRenderer doesn’t
care where a frame’s pixels came from, only that a file exists at the expected path.)
Task 6: Baked-flipbook frame loading in PlayerRenderer
Section titled “Task 6: Baked-flipbook frame loading in PlayerRenderer”Files:
- Modify:
render/player_renderer.gd - Test:
tests/test_player_renderer.gd
Interfaces:
-
Consumes:
render/ship_sprites/ship3d_cobalt_tilt_*.png(Task 5 + the addendum above, 5 files:neg30/neg15/0/pos15/pos30),PlayerRenderer.TILT_ANGLES_DEG(Task 2 + addendum,[-30, -15, 0, 15, 30]). -
Produces: real bodies for
_load_tilt_frames()and_apply_nearest_tilt_frame(rad)(replacing the Task 3 no-op stubs). -
Step 1: Write the failing tests
Add to tests/test_player_renderer.gd:
func test_baked_flipbook_mode_loads_a_tilt_frame_per_angle_for_the_default_hull() -> void: if not PlayerRenderer.USE_BAKED_HULL or ShipBonuses.DEFAULT_SHIP != "cobalt": pass_test("baked tilt frames only exist for the cobalt hull") return var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.BAKED_FLIPBOOK) assert_eq(p._tilt_frames.size(), PlayerRenderer.TILT_ANGLES_DEG.size(), "one loaded frame per angle in TILT_ANGLES_DEG, including 0") for tex in p._tilt_frames: assert_not_null(tex, "every baked tilt frame for cobalt loads successfully")
func test_baked_flipbook_snaps_to_the_nearest_angle() -> void: if not PlayerRenderer.USE_BAKED_HULL or ShipBonuses.DEFAULT_SHIP != "cobalt": pass_test("baked tilt frames only exist for the cobalt hull") return var p := _pr() p.set_tilt_mode(PlayerRenderer.TiltMode.BAKED_FLIPBOOK) p.set_tilt(deg_to_rad(28.0)) # nearest angle in TILT_ANGLES_DEG is 30 var pos30_tex: Texture2D = p._tilt_frames[p.TILT_ANGLES_DEG.find(30)] assert_eq(p._baked.texture, pos30_tex, "snaps to the nearest baked angle (30, not 15)") p.set_tilt(0.02) # close to 0 -> nearer to 0 than to +/-15 var zero_tex: Texture2D = p._tilt_frames[p.TILT_ANGLES_DEG.find(0)] assert_eq(p._baked.texture, zero_tex, "near-zero tilt snaps to the baked 0-degree frame")
func test_switching_off_baked_flipbook_restores_the_original_base_sprite() -> void: if not PlayerRenderer.USE_BAKED_HULL or ShipBonuses.DEFAULT_SHIP != "cobalt": pass_test("baked tilt frames only exist for the cobalt hull") return var p := _pr() var base_tex := p._baked.texture # the plain ship3d_cobalt.png loaded in _ready, pre-tilt p.set_tilt_mode(PlayerRenderer.TiltMode.BAKED_FLIPBOOK) p.set_tilt(deg_to_rad(30.0)) assert_ne(p._baked.texture, base_tex, "sanity: a real tilt swapped the texture") p.set_tilt_mode(PlayerRenderer.TiltMode.OFF) assert_eq(p._baked.texture, base_tex, "OFF mode restores the original (untilted) base sprite")- Step 2: Run to verify failure
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: FAIL — _tilt_frames stays empty (the Task 3 stubs are no-ops).
- Step 3: Implement frame loading + selection
Add a field near _tilt_shader_mat:
var _tilt_frames: Array[Texture2D] = []var _tilt_frames_loaded := falseReplace the Task 3 stub bodies:
func _load_tilt_frames() -> void: if _tilt_frames_loaded: return _tilt_frames_loaded = true _tilt_frames.clear() for deg in TILT_ANGLES_DEG: var suffix: String if deg < 0: suffix = "neg%d" % -deg elif deg > 0: suffix = "pos%d" % deg else: suffix = "0" var path := "res://render/ship_sprites/ship3d_%s_tilt_%s.png" % [baked_class, suffix] _tilt_frames.append(load(path) if ResourceLoader.exists(path) else null)
func _apply_nearest_tilt_frame(rad: float) -> void: if _baked == null or _tilt_frames.is_empty(): return var deg := rad_to_deg(rad) var best_i := 0 var best_dist: float = INF for i in range(TILT_ANGLES_DEG.size()): var d: float = abs(float(TILT_ANGLES_DEG[i]) - deg) if d < best_dist: best_dist = d best_i = i var tex: Texture2D = _tilt_frames[best_i] if tex != null: _baked.texture = texEvery angle (including level flight) now snaps to a real baked frame — there’s no special-case
fallback to the original ship3d_cobalt.png mid-flight, since 0° is baked like any other angle.
The original base sprite is only restored when leaving BAKED_FLIPBOOK mode entirely (below).
Note: _load_tilt_frames() is already called from _apply_tilt_mode() (Task 3) whenever
tilt_mode becomes BAKED_FLIPBOOK, and _apply_tilt_mode()’s else branch (any non-
SHADER_WARP mode, including switching to OFF) already sets _baked.material = null but does
not touch _baked.texture — add one line to _apply_tilt_mode()’s else branch to restore
the base texture when leaving BAKED_FLIPBOOK mode:
else: _baked.material = null if _tilt_frames_loaded: _baked.texture = load("res://render/ship_sprites/ship3d_%s.png" % baked_class)-
Step 4: Run
godot --headless --path . --import -
Step 5: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_player_renderer -gexit
Expected: PASS.
-
Step 6: Boot-check, Step 7: Full suite + count guard, Step 8: Determinism re-check — same commands as prior tasks. Expected baseline unchanged (
4217109746/2666143677). -
Step 9: Commit
git add render/player_renderer.gd tests/test_player_renderer.gdgit commit -m "$(cat <<'EOF'feat(render): baked-flipbook ship tilt mechanism
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>EOF)"After the plan: getting Chris’s on-device comparison
Section titled “After the plan: getting Chris’s on-device comparison”This plan’s tasks land the feature behind the dev-only “Ship Tilt” row — nothing changes for a
normal run until that row is touched (TiltMode.OFF is the default everywhere). Once all 6 tasks
are merged:
- Play it on the Mac (
godot --path .), open the pause menu → Debug Settings, cycle the Ship Tilt row through Shader Warp and Baked Flipbook while flying around, and compare feel/look against Off. - If either mechanism looks worth taking further, bump
Sim_Const.BUILDand run thebh-deployskill to get it onto the Apple TV for a real-hardware look — the design spec’s whole reason for the dev toggle is that the Mac’s Forward+ preview and the TV’s Mobile/Metal renderer have already disagreed once on this exact ship (the live-3D spike, git95bfdfd), so a look that’s convincing on the Mac still needs the TV check before it’s trusted. - Chris picks a winner (or neither); the losing mechanism’s code either stays behind the dev toggle for reference or gets deleted in a follow-up chunk — not decided by this plan.