Skip to content

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.

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.

  • 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:
    1. A shader-warp prototype (no new art).
    2. A baked multi-angle flipbook (reuses the proven-safe offline bake pipeline).
  • A dev-only cycle-row toggle in DebugSettingsPanel to 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.

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.

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.

  • 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. /sim is untouched, so the pinned state_checksum()/snapshot_string().hash() baseline is unaffected — confirm unchanged after implementation, as with every prior render-side feature in this repo’s history.

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.