Skip to content

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.

  • /sim/* stays pure logic (extends RefCounted, no Node/Engine/Input/Time/OS/File/JSON APIs) — this feature must not touch /sim at 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 in tests/test_determinism_checksum.gd / tests/test_determinism_crystals.gd before 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, run godot --headless --path . --import before 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 with timeout (not on macOS PATH here — use gtimeout if 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 -gexit then bash scripts/check-test-count.sh. Trust the count, not just “all green.”
  • GUT assertion names: assert_lte/assert_gte (NOT assert_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; only cobalt’s tier-0 procedural recipe is known- good via Ship3DRenderer.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 (git 95bfdfd). Ship3DRenderer in this plan is only ever used from the offline, windowed tools/ship_preview tool, never instantiated in-game.

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

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 ShipTilt
extends 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 extremes
const RATE_GAIN := 2.2 # TUNABLE: how strongly turn-rate maps to the tilt target
const 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 (new class_name file)

  • 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 -gexit
bash scripts/check-test-count.sh

Expected: 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
Terminal window
git add render/ship_tilt.gd tests/test_ship_tilt.gd
git 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 (near main.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.TiltMode enum (OFF = 0, SHADER_WARP = 1, BAKED_FLIPBOOK = 2), PlayerRenderer.tilt_mode: int (default TiltMode.OFF), PlayerRenderer.set_tilt_mode(mode: int) -> void, PlayerRenderer.set_tilt(rad: float) -> void (no-op when tilt_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.OFF

Add 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.0
var _prev_facing_for_tilt: float = 0.0

Then 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 new class_name files 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 -gexit
bash 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
Terminal window
git add main.gd render/player_renderer.gd tests/test_player_renderer.gd
git 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
)"

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 working SHADER_WARP branch of set_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: ShaderMaterial

Replace 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
_:
pass

Add 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

Terminal window
git add render/player_renderer.gd tests/test_player_renderer.gd
git 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, mirrors apply_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 = v

Then 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 := 0

Add 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

Terminal window
git add ui/debug_settings_panel.gd tests/test_debug_settings_panel.gd
git 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 .import sidecar).

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() to render/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_tilt variant to tools/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()
return

Add 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 new class_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_preview variant)

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
Terminal window
ls -la render/ship_sprites/ship3d_cobalt_tilt_*.png

Expected: 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 .import sidecars

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

Terminal window
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.import
git 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:

  1. PlayerRenderer.TILT_ANGLES_DEG is [-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.
  2. 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 := false

Replace 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 = tex

Every 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

Terminal window
git add render/player_renderer.gd tests/test_player_renderer.gd
git 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:

  1. 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.
  2. If either mechanism looks worth taking further, bump Sim_Const.BUILD and run the bh-deploy skill 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, git 95bfdfd), so a look that’s convincing on the Mac still needs the TV check before it’s trusted.
  3. 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.