Premium haptics: iPhone CoreHaptics (design)
Premium haptics: iPhone CoreHaptics (design, 2026-10-06)
Section titled “Premium haptics: iPhone CoreHaptics (design, 2026-10-06)”Research: docs/research/2026-10-05-corehaptics-godot.md. Builds on the merged gameplay haptics
(docs/superpowers/specs/2026-10-05-haptics-design.md, input/haptics_director.gd,
input/haptics_output.gd).
Make haptics feel premium on iPhone by driving the Taptic Engine through CoreHaptics (transients, sharpness, parameter curves), redesign every gameplay pattern, and add four new events. Controllers keep two-motor rumble, but get proper patterns for every event.
Decisions (Chris, 2026-10-05/06)
Section titled “Decisions (Chris, 2026-10-05/06)”- Scope: iPhone first. Chris tests with Xbox and MFi pads and has no DualSense. On those pads, controller CoreHaptics only approximates today’s two-motor rumble, so controller CoreHaptics (GCController haptics, joypad-to-controller matching) is out of scope. It becomes its own spec if a DualSense arrives or Xbox trigger rumble is wanted.
- iOS build only. No tvOS or macOS native build. This removes the godot-cpp tvOS and fork-ABI risks from the research doc.
- Tuning: Haptics Lab + Remote Console. Patterns are data. A dev-only Lab plays each one, and console sliders scale intensity and sharpness live.
- New events: Lifesteal (Leech) heal, weapon evolution, low-hull heartbeat, NUKE pickup, warp ready.
Architecture
Section titled “Architecture”HapticsDirector (render-side, frame-diffs sim state) │ emits pattern ids: play(pilot, "hit", scale) ▼HapticsOutput (routing: which pads / handheld per pilot, the On/Off switch) ├── iPhone, extension present, handheld route → DcHaptics.play_pattern(ahap_json, scale…) └── everything else (pads, Android, no extension) → pattern's pad fallback → start_joy_vibration / vibrate_handheld1. Native extension: platform/tvos/addons/dc_haptics/
Section titled “1. Native extension: platform/tvos/addons/dc_haptics/”A small ObjC++ godot-cpp GDExtension, built only for iOS as a static .xcframework. It ships the same
way as platform/tvos/addons/sentry/ (an ios.debug/ios.release entry in a .gdextension). It
lives in platform/tvos/ because that project exports iOS. The root project never loads it.
- Source:
platform/tvos/addons/dc_haptics/src/with a build scriptplatform/tvos/addons/dc_haptics/build.sh(SCons, godot-cpp pinned to the 4.7 tag as a submodule or a pinned clone outside the repo). Built binaries are committed, as with Sentry. - Singleton class
DcHaptics(Engine singleton registered at init):supports_haptics() -> bool:CHHapticEngine.capabilitiesForHardware.supportsHaptics.prepare() -> void: create the engine and start it. Called once at boot.play_pattern(ahap_json: String, intensity_scale: float, sharpness_scale: float) -> void: parse the AHAP dictionary, apply the scales, build aCHHapticPatternand play it on a one-shot player. Patterns are cached by their JSON hash after the first parse.play_transient(intensity: float, sharpness: float) -> void: used for the per-hit damage-scaled tap.stop_all() -> void.
- Engine lifecycle:
stoppedHandlerandresetHandlermark the engine stopped or restart it, and the next play call restarts it lazily.isAutoShutdownEnabled = YESsaves power when idle.playsHapticsOnly = YES(we do not play audio through CoreHaptics). - The system “System Haptics” setting is honoured by CoreHaptics itself, so we add no logic for it.
- Failure policy: any
NSErroris logged once per error code and swallowed. A haptics failure must never surface to gameplay.
2. Patterns as data: haptics/patterns/*.json
Section titled “2. Patterns as data: haptics/patterns/*.json”One file per pattern id. Each file holds:
"ahap": an Apple AHAP dictionary (Version,PatternwithEvents andParameterCurves), so a pattern can be authored or checked in Apple’s tools."pad": the fallback, an array of pulses[{ "weak": f, "strong": f, "ms": n, "at_ms": n }]played through the existing two-motor path. This covers pads on every platform, Android phones, and iPhone without the extension.
HapticPatterns (input/haptic_patterns.gd) loads them once and validates them: known keys only,
values in 0..1, at_ms ascending. It exposes ahap_json(id) and pad(id). An invalid file is
rejected by a GUT test, not at runtime.
3. Pattern set (first draft, to be tuned in the Lab)
Section titled “3. Pattern set (first draft, to be tuned in the Lab)”| id | Trigger | iPhone feel (AHAP) | Pad fallback |
|---|---|---|---|
hit |
hp drop ≥ 2% max HP (existing accumulator) | transient, intensity scales with damage, sharpness 0.7; heavy hits (≥ 10% max HP) add a 120 ms continuous tail at sharpness 0.2 | today’s damage-scaled pulse |
down |
pilot hp reaches 0 | transient 1.0/0.3, then continuous 0.8 decaying to 0 over 600 ms | strong 1.0, 400 ms |
level_up |
pending_levelups rises | two transients 0.6/0.9, 80 ms apart | today’s double tap |
evolve |
evolve fx event for this pilot |
three transients rising 0.4→0.8 intensity and 0.5→1.0 sharpness, 70 ms apart, then a 300 ms swell | three rising weak pulses, then one strong |
leech |
accumulated Leech heal ≥ 3% max HP, at most once per 400 ms | transient 0.35/0.1 (soft, dull: reads as “warm”) | weak 0.25, 25 ms |
heartbeat |
hp < 25% max HP, repeats every 1100 ms while true | “lub-dub”: transients 0.5/0.2 then 0.35/0.2, 140 ms apart | strong 0.4 + 0.25, 30 ms each |
boss_windup |
Warden fire_charge rising |
continuous event; intensity and sharpness follow the charge (refreshed every 100 ms, as today) | today’s 0.15 + 0.35×charge pulse |
boss_kill |
“BOSS DOWN” reaction | transient 1.0/1.0, then continuous 1.0 decaying over 900 ms | strong 1.0, 300 ms |
nuke |
“NUKE” reaction | transient 1.0/0.15, then continuous 0.6 for 250 ms | strong 0.9, 250 ms |
warp |
dash fires (dash_cd edge) | continuous, intensity 0.8→0 over 80 ms, sharpness 0.8 | today’s 18 ms ability tap |
decoy |
drone_charge spent | transient 0.5/0.8 | today’s tap |
warp_ready |
dash_cd returns to 0 | transient 0.25/1.0 | weak 0.15, 15 ms (pads only; skipped on touch where the button arc already shows it) |
The heartbeat is suppressed while down, while paused, and while a boss_windup is playing (one
dominant rhythm at a time). The hit cooldown (150 ms) stays.
4. Output routing changes
Section titled “4. Output routing changes”HapticsOutput.pulse(pilot, weak, strong, ms, handheld_ok) is replaced by
HapticsOutput.play(pilot, id, scale := 1.0, handheld_ok := true). The routes (routes_for) and the
enabled switch are unchanged. Per route:
- handheld route on iOS with
DcHapticspresent andsupports_haptics()→play_patternwith the global and per-id scales; - otherwise → schedule the pattern’s
padpulses (a small scheduler inHapticsOutputdriven by the director’snow_ms, so multi-pulse patterns work on pads).
The seams _joy, _handheld, _joy_stop stay, plus a new _native_play seam, so
tests/haptics_spy.gd covers every path headless.
5. Sim change: Leech heal visibility
Section titled “5. Sim change: Leech heal visibility”Leech heal is paid in Sim._sweep_dead() from the per-tick lifesteal_dealt. HP also rises from
regen and stand-still heal, so the director cannot tell Leech apart by diffing HP. Add a per-pilot
leech_healed: float accumulator on PlayerState, incremented by the HP actually gained (after
the max_hp clamp) where the heal is paid (sim/sim.gd _sweep_dead, the mods.lifesteal
block). Leech pays a little every tick while firing and is a shared party mod, so the director
accumulates per pilot and plays leech only past 3% max HP and at most every 400 ms; a pilot at
full HP gains nothing and feels nothing.
- It is render-read only and must stay out of the determinism checksum. The three seed-1234
baselines (
tests/test_determinism_survival.gd,test_determinism.gd,test_determinism_coop.gd) must pass unchanged. A re-pin is a defect here, not a ruling. - A test drives Leech through the real kill →
_sweep_deadpath and assertsleech_healedrose.
6. Tuning surfaces
Section titled “6. Tuning surfaces”- Haptics Lab (
ui/haptics_lab_panel.gd, opened from Debug Settings, dev builds only viaBuildConfig.dev_tools()): one Play button per pattern id, global intensity and sharpness multipliers, and a readout of which path played (native, pad fallback, or none). It must fit the iPhone profile inShopLayoutFixture. - Remote Console: tunables
haptics.intensityandhaptics.sharpness(global, 0..2, default 1), plushaptics.<id>.intensityper pattern, registered innet/tunables_registry.gd. They regenerate the manifest, and per the Debug Settings ↔ console parity rule each Lab slider has a console counterpart. Once a pattern feels right, the tuned values are baked back into its JSON and the per-id tunable returns to 1.
7. Lifecycle
Section titled “7. Lifecycle”main._notification already calls HapticsOutput.stop_all() on pause and focus loss. That now also
calls DcHaptics.stop_all(). DcHaptics.prepare() runs once in main._ready on iOS.
HapticsOutput.enabled = false short-circuits before any native call.
Testing
Section titled “Testing”- GUT: pattern file validation (all ids present, values in range); director → pattern id mapping for
each of the 12 events, including heartbeat cadence and suppression; pad-fallback scheduling of
multi-pulse patterns; routing to
_native_playversus the pad path; Leech counter through the real kill path; the three determinism baselines untouched. - The native layer has no headless tests. It is verified on iPhone through the Lab: every pattern plays, there are no console errors, and haptics survive backgrounding and returning.
- On-device feel check: iPhone (native) and Apple TV with an Xbox pad (pad fallback for the four new
events). The checklist is appended to
docs/architecture/coop-and-pilots.md§ Haptics. scripts/gate.shgreen; ui-sweep for the Lab panel at device profiles.
Out of scope
Section titled “Out of scope”Controller CoreHaptics (GCController), DualSense adaptive triggers, Xbox trigger rumble, Mac trackpad haptics, Siri Remote, audio-coupled AHAP, and tvOS/macOS native builds.
- godot-cpp on iOS against stock 4.7 export templates. Sentry proves the path works for
GDExtensions in general, but our build script is new. Task 1 of the plan is a spike: build an
empty extension, export, confirm
DcHapticsloads on iPhone. If it fails, fall back to a stock Godot iOS plugin (.gdip), which has the same Singleton API. - Pattern feel is subjective. That is why patterns are data plus the Lab: tuning iterations need no rebuild.
- Heartbeat fatigue. If the 25% heartbeat proves annoying, the threshold and period are tunables, and the pattern can be turned off per id in the Lab.