Player Ship Tilt (banking) — design
Player Ship Tilt (banking) — design
Section titled “Player Ship Tilt (banking) — design”Date: 2026-07-06
Status: approved (design), pre-implementation
Determinism baseline at design time: snapshot_string().hash()=4217109746, state_checksum()=2666143677 → read the literal pinned assertion in tests/test_determinism_checksum.gd / test_determinism_crystals.gd, not this note.
1. Concept & scope
Section titled “1. Concept & scope”Chris has seen bullet-hell games on Apple TV where the player ship visibly tilts/banks
as it turns — a cheap, classic trick that reads as “real 3D” even in a flat top-down game.
Origin of this idea: reviewing ~/Claude/godot-graphics-showcase (a standalone Godot
technique library) for anything worth bringing into Dark Cosmos surfaced hero_lights.gd’s
deferred “faux-3D” path, which led into a broader conversation about live-3D-in-a-2D-game —
and from there to this feature.
Load-bearing prior art, already in this repo: a live-3D player ship (Ship3DRenderer in a
SubViewport) was already spiked and MEASURED on-device (main.SHIP3D_MEASURE, BUILD 118).
It broke on the Apple TV’s Mobile/Metal renderer — transparent_bg failed (opaque dark
squares) and PBR metal degraded to a flat emissive triangle, despite looking correct on the
Mac’s Forward+ preview (git 95bfdfd). The team’s answer was to bake the 3D render
offline to a sprite instead (tools/ship_preview’s bake variant → render/ship_sprites/),
which is what PlayerRenderer.USE_BAKED_HULL shows today: full 3D look, zero live-3D runtime
cost, correct on every renderer. This spec must not reintroduce live SubViewport-3D at
runtime — both mechanisms below are chosen specifically to avoid that landmine.
The existing ship rotation is yaw only: main.gd already smoothly rotates the whole ship
body to face its current movement direction (_player_facing = _move_delta.angle() + PI/2,
lerped every frame). Tilt is a different axis (roll/bank) layered on top of that existing
yaw, not a replacement for it.
In scope
Section titled “In scope”- Player ship only (frigate + cruiser hulls). Enemies/bosses are explicitly out of scope for this pass, even though Orbiter/Warden now have real sprite art too.
- A shared, pure, headless-testable tilt-driver function.
- Two rendering mechanisms, both wired up so Chris can compare them live, on-device,
before either becomes the default:
- A shader-warp prototype (no new art).
- A baked multi-angle flipbook (reuses the proven-safe offline bake pipeline).
- A dev-only cycle-row toggle in
DebugSettingsPanelto switch between: Off / Shader Warp / Baked Flipbook. - GUT tests for the tilt-driver function.
Out of scope (future, pending the comparison)
Section titled “Out of scope (future, pending the comparison)”- Picking a permanent default — that’s Chris’s call after playing both on Mac + Apple TV.
- Enemy/boss tilt (Orbiter, Warden, or any other sprite-based enemy).
- Any live-3D SubViewport rendering at runtime.
- Per-tier baked angle sets — tier already only drives glow/scale/accents, not hull art (BUILD 120), so the flipbook mechanism only needs one angle set per ship class (frigate hulls, cruiser), not per tier.
2. Shared tilt-driver
Section titled “2. Shared tilt-driver”A single pure function computes a smoothed, clamped, signed tilt value from the rate of change of the ship’s existing facing angle — banking into a sharp turn, leveling out when flying straight or steady. This is deliberately not driven by raw move-stick position: since the whole ship body already reorients to face movement direction (rather than staying at a fixed heading like a Darius-style strafer), banking-by-turn-rate is the analog that actually matches how this ship already moves.
Lives in a new small file, e.g. render/ship_tilt.gd (class_name ShipTilt extends RefCounted), following the project’s “one static/pure helper, render-side only” pattern
(SoftDotTexture, ElementPalette, etc.) — not /sim; this is cosmetic-only and must
never be able to affect determinism.
Shape: something like ShipTilt.advance(prev_tilt: float, facing: float, prev_facing: float, dt: float) -> float, called once per frame from main.gd alongside the existing facing
lerp. Internally: derive angular velocity from angle_difference(facing, prev_facing) / dt,
map it through a clamp + a decay/smoothing filter (so a single-frame jitter doesn’t cause a
snap), and clamp the output to a fixed max tilt magnitude. Both rendering mechanisms consume
this same output value, so switching between them via the dev toggle compares rendering
only, not two different feels.
3. Mechanism A — shader warp (no new art)
Section titled “3. Mechanism A — shader warp (no new art)”A canvas_item ShaderMaterial layered onto the existing baked-hull Sprite2D, in the same
spirit as the motion-blur smear shader already layered onto ArchetypeRenderer’s core
meshes (_SMEAR_SHADER) — an additive per-sprite material for a cosmetic transform, not a
new node type.
The shader takes a tilt uniform (signed, matching ShipTilt’s output range) and does a
perspective-style UV warp — foreshortening the leading/trailing edge relative to the turn
direction — rather than a naive horizontal skew/squash. A flat shear reads as “the sprite
got squished”; a perspective warp (moving the far edge’s UVs toward center more than the
near edge’s) reads as “the sprite is rotating away from camera,” which is the actual goal.
Exact warp math to be worked out during implementation/prototyping — this is a visual
tuning problem, not an architectural one.
Zero new textures or bake work. Same shader class (shader_type canvas_item, no
hint_screen_texture) as other per-sprite effects already in the repo — confirmed safe on
Mobile/Forward+/gl_compatibility alike, unlike the screen-space (hint_screen_texture)
effects which have their own separate track record.
4. Mechanism B — baked multi-angle flipbook
Section titled “4. Mechanism B — baked multi-angle flipbook”Extends tools/ship_preview’s existing bake variant: for each ship class (not tier),
render Ship3DRenderer at a small fixed set of roll angles (e.g. -30°/-15°/0°/+15°/+30° —
exact count/spacing to be tuned by eye) via the same offline, Mac-only, Forward+ SubViewport
pass already used for the current single-angle hull bake. Each angle saves to
render/ship_sprites/ alongside today’s ship3d_<class>.png (e.g.
ship3d_<class>_tilt<N>.png).
At runtime, PlayerRenderer picks the nearest baked frame to the current ShipTilt value
(snap-to-nearest, matching how classic sprite-based shmups — Darius, Raiden — actually did
this) instead of always showing the single flat hull sprite. Cross-fading between adjacent
frames is a possible refinement if snapping looks too discrete, but snapping is the simpler
starting point.
This is genuinely re-lit per angle (real specular/rim-light shift from the 3D bake, not a fake warp), for the cost of extra baked PNGs and re-running the bake per ship class when hull art changes. Runtime cost stays what it is today — “just a sprite” — since the bake step itself only ever runs offline on the Mac dev tool, never live in-game, exactly like the existing hull bake already works.
5. Comparison toggle
Section titled “5. Comparison toggle”A new cycle row in ui/debug_settings_panel.gd, alongside the existing background-variant
cycle row (BG_VARIANT_NAMES pattern) — same shape: a {label, is_cycle: true, get_text, advance} row, cycling ["Off", "Shader Warp", "Baked Flipbook"]. Gated behind
BuildConfig.dev_tools() like every other row in that panel.
This lets Chris flip between all three live, mid-flight, on both Mac and Apple TV, before
either mechanism becomes a shipped default — mirroring the project’s established
“spike-behind-a-flag, measure on real hardware, then decide” discipline already used for
SHIP3D_MEASURE and MEASURE_FULL_QUALITY.
6. Testing & determinism
Section titled “6. Testing & determinism”ShipTilt.advance()is pure and headless-testable via GUT: feed it a sequence of facing-angle steps and assert the tilt curve ramps up on a sharp turn, decays toward 0 flying straight, and never exceeds its clamp.- Neither rendering mechanism is unit-testable (same as every other shader/sprite effect in
this repo) — verified by playing, same as
death_dissolve/screen_punch/etc. - Both mechanisms are strictly render-side: they read
player_node.rotation/facing history and write to render-only nodes/materials./simis untouched, so the pinnedstate_checksum()/snapshot_string().hash()baseline is unaffected — confirm unchanged after implementation, as with every prior render-side feature in this repo’s history.
7. Decision point
Section titled “7. Decision point”Once both mechanisms are flyable side-by-side via the dev toggle, Chris picks a winner (or neither) from feel + an on-device look on both Mac and Apple TV. The non-winning mechanism either stays available behind the dev toggle for future reference or gets deleted — that call happens after the comparison, not as part of this spec.