Skip to content

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.

  • 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.
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_handheld

1. 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 script platform/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 a CHHapticPattern and 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: stoppedHandler and resetHandler mark the engine stopped or restart it, and the next play call restarts it lazily. isAutoShutdownEnabled = YES saves 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 NSError is 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, Pattern with Events and ParameterCurves), 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.

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 DcHaptics present and supports_haptics() → play_pattern with the global and per-id scales;
  • otherwise → schedule the pattern’s pad pulses (a small scheduler in HapticsOutput driven by the director’s now_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.

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_dead path and asserts leech_healed rose.
  • Haptics Lab (ui/haptics_lab_panel.gd, opened from Debug Settings, dev builds only via BuildConfig.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 in ShopLayoutFixture.
  • Remote Console: tunables haptics.intensity and haptics.sharpness (global, 0..2, default 1), plus haptics.<id>.intensity per pattern, registered in net/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.

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.

  • 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_play versus 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.sh green; ui-sweep for the Lab panel at device profiles.

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 DcHaptics loads 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.