Gameplay haptics (design)
Gameplay haptics
Section titled “Gameplay haptics”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()callsInput.vibrate_handheldon warp / decoy / Drone Bay taps, touch only, gated byTouchControls.haptics(defaulttrue). 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-tvosfork source): iOS, tvOS and macOS gamepads all go through Godot’s SDL joypad driver (drivers/apple_embedded/os_apple_embedded.mminitialize_joypads,platform/macos/os_macos.mm), which implementsstart_joy_vibration; SDL drives MFi / DualSense / Xbox pads via GCDeviceHaptics.vibrate_handheldis CoreHaptics on iOS. The Siri Remote has no motor. Android pad rumble is unverified — device check owed.
Design
Section titled “Design”Render-side only — no /sim change
Section titled “Render-side only — no /sim change”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).
Output routing — HapticsOutput
Section titled “Output routing — HapticsOutput”A small RefCounted that maps pilot index → output:
- joypad device id →
Input.start_joy_vibration(device, weak, strong, seconds) - touch pilot (P1 while
TouchControlsis active and notPlatform.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.
Toggle
Section titled “Toggle”- A persisted
meta.haptics(defaulttrue), saved likemeta.share_diagnostics. - Pause menu gets a “Haptics On / Off” button mirroring the Diagnostics button (
ui/pause_menu.gd,haptics_toggled(on)signal;mainpersists 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_paritymust stay green. - Turning haptics off immediately calls
Input.stop_joy_vibrationon every pilot device.
Error handling
Section titled “Error handling”- 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.
Testing
Section titled “Testing”- GUT,
HapticsDirectorwith 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_maintouch-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.
Player-facing / compliance
Section titled “Player-facing / compliance”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.