Skip to content

Gameplay haptics (design)

Date: 2026-10-05 · Status: Design approved in chat (Chris); spec awaiting review

Scope: per-pilot haptic feedback for gameplay events on every device — phone vibration for a touch pilot, controller rumble for a joypad pilot — plus one player-facing on/off toggle.

Current state (verified on origin/main 2026-10-05)

Section titled “Current state (verified on origin/main 2026-10-05)”
  • ui/touch_controls.gd _haptic() calls Input.vibrate_handheld on warp / decoy / Drone Bay taps, touch only, gated by TouchControls.haptics (default true). That flag is reachable only from Debug Settings (“Touch: haptics”) and the Remote Console — store players cannot turn it off.
  • Nothing calls Input.start_joy_vibration. No gameplay event (hit, death, level-up, boss) buzzes.
  • Engine support (checked in the ~/Claude/godot-tvos fork source): iOS, tvOS and macOS gamepads all go through Godot’s SDL joypad driver (drivers/apple_embedded/os_apple_embedded.mm initialize_joypads, platform/macos/os_macos.mm), which implements start_joy_vibration; SDL drives MFi / DualSense / Xbox pads via GCDeviceHaptics. vibrate_handheld is CoreHaptics on iOS. The Siri Remote has no motor. Android pad rumble is unverified — device check owed.

A new HapticsDirector (Node, child of main) reads sim state each _process and fires buzzes. Sim is untouched, so no determinism baseline can move. It diffs per-pilot state frame to frame rather than adding sim events:

Event Detection Pattern (weak/strong, duration)
Hit taken pilot.hp < last frame’s value strength = clamp(dmg / max_hp × 4, 0.25, 1.0), 40–120 ms; per-pilot cooldown 150 ms so contact swarms don’t buzz continuously
Pilot down hp crossed from > 0 to ≤ 0 400 ms, strong 1.0
Level-up pilot.pending_levelups increased two 30 ms taps (second scheduled 80 ms later)
Boss wind-up boss_render_info().fire_charge rising weak rumble, strength = 0.15 + 0.35 × charge, re-armed every 100 ms, all pilots
Boss kill "BOSS DOWN" reaction in render_fx_events 300 ms strong, all pilots
Warp / decoy per-pilot dash/decoy press edges (same edges main already tracks) 18 ms (matches the existing touch tap)

render_fx_events is drained by main._process before children run, so main forwards the events to the director (haptics.note_events(...)), exactly as it does for telemetry.note_events (CLAUDE.md render-harness rule).

Pattern constants live in one const table in the director so feel tuning is one place; they are not remote tunables in v1 (YAGNI — promote if tuning on device proves fiddly).

A small RefCounted that maps pilot index → output:

  • joypad device id → Input.start_joy_vibration(device, weak, strong, seconds)
  • touch pilot (P1 while TouchControls is active and not Platform.force_touch) → Input.vibrate_handheld(ms, amp)
  • keyboard / no device → no-op

main hands it the same per-pilot device list it already uses for poll_device(playable[k]), so it extends to pilots 3–4 with no change. The output is injectable so tests use a spy (the EventQueue.push lesson: platform calls are silent no-ops headless).

The existing TouchControls._haptic taps are re-pointed through the shared enabled flag so there is ONE switch for all haptics.

  • A persisted meta.haptics (default true), saved like meta.share_diagnostics.
  • Pause menu gets a “Haptics On / Off” button mirroring the Diagnostics button (ui/pause_menu.gd, haptics_toggled(on) signal; main persists it).
  • The Debug Settings row is renamed “Haptics” and binds the same value; the Remote Console flag (touch:haptics) keeps its key for compatibility and also binds it. test_debug_console_parity must stay green.
  • Turning haptics off immediately calls Input.stop_joy_vibration on every pilot device.
  • A device that disconnects mid-rumble: SDL drops the call; the director never caches device validity beyond the current frame’s list.
  • Pause / level-up panels: the director skips while the sim is paused, and stops all rumble on pause so a boss wind-up rumble doesn’t continue under the menu.
  • Run end / _new_run: director state (last hp etc.) is reset with the pilot list.
  • GUT, HapticsDirector with a fake sim state + spy output: each detector fires once with the right pilot/pattern; the 150 ms hit cooldown; pilot-down beats hit on the killing frame; boss events reach every pilot; toggle off → zero calls; paused → zero calls.
  • GUT, HapticsOutput: joypad vs touch vs none routing.
  • Pause-menu toggle emits + persists; meta save/load round-trip of haptics.
  • Existing test_debug_console_parity / test_main touch-flag tests updated for the rename.
  • On-device feel check (Chris): iPhone (touch), Apple TV + MFi pad, Mac + pad; Android pad rumble noted as an open check.

No new data is collected, so no privacy page or ASC label change. Add a What’s new entry (control/src/whats_new.js, build: null) when merged.