Skip to content

CoreHaptics for Godot (research)

CoreHaptics for Godot: premium haptics on Apple platforms (research, 2026-10-05)

Section titled “CoreHaptics for Godot: premium haptics on Apple platforms (research, 2026-10-05)”

Read-only research for a design decision. Question: how do we get CoreHaptics-grade haptics (sharpness, transients, AHAP patterns, parameter curves) on (a) the iPhone Taptic Engine, (b) game controllers via GCController.haptics on iPhone/iPad, Apple TV and Mac, and (c) anything else on Mac, given the engine setup: stock Godot 4.7 for iOS and macOS, and the tvOS fork at ~/Claude/godot-tvos/godot (4.7.dev, stuartcarnie/godot, HEAD 007f6f1).

Confidence tags: [Certain] = verified in source or in a primary doc. [Likely] = strong secondary evidence. [Guessing] = inference, needs a device test.

  • No existing plugin does what we want. Every Godot iOS haptics plugin wraps UIImpactFeedbackGenerator (light/medium/heavy). None exposes sharpness, transients or AHAP, and none targets tvOS or controller haptics on iOS/tvOS. The closest prior art is Sineaggi/godot-dualsense, which runs DualSense haptics through GameController + CoreHaptics on macOS arm64 only, verified on hardware alongside Godot’s SDL. It is a reference implementation to read, not a dependency. [Certain]
  • Build our own small ObjC++ GDExtension (godot-cpp), shipped as a static .xcframework (iOS device + simulator, tvOS) plus a macOS .framework/.dylib. One codebase and one .gdextension file can cover all three platforms: stock 4.7 iOS and the tvOS fork share the same EditorExportPlatformAppleEmbedded GDExtension static-link path. [Certain, from source]
  • Achievable coverage: iPhone Taptic: full CoreHaptics. Controllers on iOS/iPadOS/tvOS/macOS: CoreHaptics through GCDeviceHaptics. That means real transients and sharpness on DualSense (voice coils), and approximations on DualShock 4 / Xbox (ERM/LRA rumble motors, plus Xbox trigger motors). Apple TV itself and the Siri Remote: nothing. Mac: controllers only, because the trackpad API cannot be used for gameplay.

1. Existing Godot plugins and GDExtensions

Section titled “1. Existing Godot plugins and GDExtensions”
Project What it does Platforms Activity / Godot License Verdict
kyoz/godot-haptics Haptics.light/medium/heavy() Android + iOS (.gdip in ios/plugins) Pushed 2026-09-15; per-Godot-version releases incl. 4.7 MIT No sharpness/AHAP, no tvOS, no controllers [Certain]
toniqat/godot-haptics (fork of the above) Adds selection/notification styles and tuned waveforms. README: “iOS is UIKit-only by design… Custom AHAP waveforms would require CoreHaptics.framework to be weak-linked through the .gdip” Android + iOS .gdip Pushed 2026-09-26, 1 star MIT Explicitly not CoreHaptics [Certain]
GhostWalker562/Godot-Haptic-Feedback-Module-IOS UIFeedbackGenerator module iOS, Godot 3.x era (engine module, replaces the .a) Last push 2025-09 none stated Dead end [Certain]
extrawurst/godot-ios-impact-plugin UIImpactFeedbackGenerator iOS old — Dead end [Likely]
Sineaggi/godot-dualsense DualSense adaptive triggers (via Godot’s own SDL, dlsym) + speaker-coil haptics via GameController + CoreHaptics (haptic_tap(device, intensity, sharpness, locality), haptic_buzz, per-hand localities) macOS arm64 only, Godot 4.7+ GDExtension Last commit 2026-08-03 Apache-2.0 / MIT Best prior art. Its docs/dualsense-porting-guide.md and src/haptics_gc.{h,mm} are worth reading before we write ours [Certain]
theisegoria/tactile DualSense HD haptics, triggers, LEDs over Bluetooth for macOS apps/engines (Swift + C ABI) macOS 2026 — macOS-only, raw-device approach [Likely]
Lelisvaldo/godot-dualsense-native, rafaelvaloto/katamaritaco Godot-Dualsense, parrssee/godot-dualsense DualSense over raw HID (hidapi / HidSharp) Windows/Linux (one macOS PR) Sep 2026 MIT / other Raw HID: no iOS/tvOS (no HID access in the sandbox), conflicts with SDL [Certain]
timoschwarzer/godot-audio-haptics Audio-bus-driven DualSense haptics Linux (PipeWire/Pulse), Windows untested — MIT Not Apple [Certain]

Searches on GitHub for “godot corehaptics”, “godot gccontroller”, “godot ios haptic” and “godot apple plugin haptic” returned no repos (2026-10-05). [Certain]

Upstream Godot: Input.vibrate_handheld on iOS already uses CHHapticEngine (PR #60397, amplitude arg #91143), but only as a single continuous event with intensity. There is no sharpness and no transient (drivers/apple_embedded/apple_embedded.mm). Open upstream PRs: #111682 (DualSense adaptive triggers + custom joypad packets, open, 4.x) and #114642 (SDL Haptic subsystem, DirectInput-style force-feedback effects, open). Neither is CoreHaptics, and SDL has no haptic driver on iOS/tvOS anyway (SDL_HAPTIC_IOKIT is macOS-only in drivers/sdl/SDL_build_config_private.h). Nothing upstream will deliver this soon. [Certain]

  • Both mechanisms exist. [Certain]
    • Legacy .gdip plugins in res://ios/plugins/: a static .a/.xcframework with initialization/deinitialization symbols, linked/embedded/system frameworks, plist keys and capabilities. The 4.7 docs page still documents it and does not deprecate it, though it carries a “not yet updated for 4.7” banner.
    • GDExtension: editor/export/gdextension_export_plugin.h handles .a/.xcframework libraries for any apple_embedded target. It generates a $ENTRY_init stub that calls register_dynamic_symbol() and add_apple_embedded_platform_init_callback(), and adds -Wl,-U,_$ENTRY. The extension is statically linked into the app binary, which is how iOS forbids dlopen from being a problem.
  • We already ship a GDExtension on iOS: platform/tvos/addons/sentry/sentry.gdextension uses ios.release = ".../libsentry.ios.release.xcframework" (dormant Sentry). So the pipeline is proven in our export. [Certain]
  • Recommendation: GDExtension, not .gdip. The same artefact and API work on macOS and tvOS, and GDScript gets real classes instead of Engine.get_singleton(). A .gdip is iOS/tvOS only and is built against engine headers, so it must be rebuilt per engine version. [Likely, design judgement]
  • Note: stock 4.7.0/4.7.1 iOS templates had a link failure (_SDL_IsIPad/_SDL_IsAppleTV, #122549, fixed by #123903 for 4.8). It is unrelated to plugins, but evidence that stock 4.7 iOS now uses SDL MFi joysticks (#114316, merged for 4.7). [Certain]
  • platform/tvos/export/export_plugin.h: EditorExportPlatformTVOS : EditorExportPlatformAppleEmbedded. Platform features are apple_embedded + tvos, so both .gdip plugins (from res://tvos/plugins/, get_plugins(p_platform_name)) and static GDExtensions (the shared gdextension_export_plugin.h branch keyed on apple_embedded) work. Runtime register_dynamic_symbol lives in the shared drivers/apple_embedded/os_apple_embedded.mm. [Certain, from source]
  • .gdextension keys: tvos.release = "res://addons/hx/bin/libhx.xcframework" (the tvos feature tag). [Likely; the feature lookup uses the platform features above]
  • Build-side gap: godot-cpp’s SCons tools have ios.py (iphoneos/iphonesimulator) and macos.py but no tvOS target (godot-cpp/tools). Build the tvOS slice with godot-cpp’s CMake (CMAKE_SYSTEM_NAME=tvOS, -mappletvos-version-min=26.0 to match the fork’s get_minimum_deployment_target() == "26.0"), or patch ios.py to take an appletvos SDK. A small one-off. [Certain that it is missing; Likely that CMake works]
  • API compatibility: the fork reports 4.7.0 dev. Build godot-cpp against the 4.7 stable extension_api.json with compatibility_minimum = "4.7". Verify the fork’s GDExtension interface matches by dumping --dump-extension-api from the fork and diffing. [Guessing that it matches; must verify]
  • Standard GDExtension: macos.release = ".../libhx.macos.framework" (or a .dylib, which is what Sentry does). It must be signed in the exported .app; App Store builds need it inside Contents/Frameworks and signed with the app’s identity. [Certain for the mechanism; Likely for the signing details]

Yes. [Likely, high confidence] The same ObjC++ source (CHHapticEngine, GCController, GCDeviceHaptics all exist on iOS 14+, tvOS 14+, macOS 11+, per Apple’s platform metadata for GCDeviceHaptics) compiles to:

  • libhx.xcframework with slices ios-arm64, ios-arm64-simulator and tvos-arm64 (static), and
  • libhx.macos.framework (dynamic, universal or arm64).

Use #if TARGET_OS_IOS only around the phone-Taptic path (CHHapticEngine capabilitiesForHardware], UIApplication lifecycle). Everything controller-related is common code.

Swift vs ObjC++: use ObjC++. Swift in a static library linked into Godot’s generated Xcode project needs the Swift runtime auto-linked into the host target. The fork has platform/ios/app_ios.swift + platform/tvos/app_tvos.swift (Swift app entry), so Swift may already be present there [Certain for the fork], but whether stock 4.7’s iOS template project carries Swift sources is unverified [Guessing]. ObjC++ avoids the question entirely, and godot-cpp is C++ anyway.

3. Conflict with SDL, and device-id mapping

Section titled “3. Conflict with SDL, and device-id mapping”

What SDL does today (vendored SDL 3.2.28, thirdparty/sdl/joystick/apple/SDL_mfijoystick.m) [Certain]

Section titled “What SDL does today (vendored SDL 3.2.28, thirdparty/sdl/joystick/apple/SDL_mfijoystick.m) [Certain]”
  • On connect, it reads controller.haptics.supportedLocalities only to set CAP_RUMBLE (Handles) and CAP_TRIGGER_RUMBLE (Triggers) (lines ~932-937).
  • Lazily, on the first SDL_RumbleJoystick / SDL_RumbleJoystickTriggers call (lines 1409-1455), it creates up to four CHHapticEngines via createEngineWithLocality: (LeftHandle = low-frequency motor, RightHandle = high-frequency, LeftTrigger, RightTrigger). Each plays one infinite HapticContinuous event at intensity 1.0, and amplitude is driven by CHHapticDynamicParameterIDHapticIntensityControl. No sharpness, no transients. Engines are torn down on close.
  • Godot’s JoypadSDL maps Input.start_joy_vibration(device, weak, strong, dur) to SDL_RumbleJoystick(strong, weak, ms) (drivers/sdl/joypad_sdl.cpp:122).

Implication: if our plugin owns controller haptics on Apple and the game stops calling Input.start_joy_vibration on those platforms, SDL never creates an engine and there is nothing to conflict with. That is the clean design: route Apple controllers to the plugin through HapticsOutput._joy (the existing platform seam in input/haptics_output.gd), and fall back to start_joy_vibration only when the plugin reports no haptics. [Certain about SDL’s behaviour; design follows]

macOS is different: SDL uses HIDAPI for some pads [Certain, from source]

Section titled “macOS is different: SDL uses HIDAPI for some pads [Certain, from source]”

SDL_JOYSTICK_HIDAPI is enabled on macOS only (SDL_build_config_private.h). The MFi driver skips devices HIDAPI claims (SDL_mfijoystick.m:354-365):

  • DualSense / DualShock 4: HIDAPI (raw HID output reports for rumble).
  • Xbox One/Series over Bluetooth: HIDAPI. Wired Xbox: GCController, because SDL_hidapi_xboxone.c:353 says macOS cannot write wired output reports.

So on macOS, SDL rumble and CoreHaptics are different OS paths. Sineaggi’s hardware matrix (DualSense, macOS, USB + BT) found “Contention with rumble emulation is deterministic: CoreHaptics wins”: SDL rumble is ignored while a CoreHaptics pattern plays. Rule: one system at a time per pad. [Certain per their verified report; Likely it generalises to Xbox BT]

Can a second CHHapticEngine share controller.haptics with SDL’s?

Section titled “Can a second CHHapticEngine share controller.haptics with SDL’s?”
  • Apple: createEngine(withLocality:) returns a new engine. Apple’s doc suggests creating several engines with different localities (“use the left handle actuator as a woofer and the right actuator as a tweeter”), and WWDC20 says the system “will automatically combine the haptics from all active patterns for you at runtime” (WWDC20 10614, createEngine doc). [Certain]
  • Two engines on the same locality from the same process (ours + SDL’s): undocumented. Playback goes through gamecontrollerd.haptics, so mixing is plausible, but don’t depend on it. Use the “don’t call SDL rumble on Apple” rule above. [Guessing]

Mapping Godot joypad id → GCController [Likely; needs a device test]

Section titled “Mapping Godot joypad id → GCController [Likely; needs a device test]”

Godot exposes Input.get_joy_guid(id) and get_joy_info(id) (vendor_id, product_id, raw_name). There is no pointer to the GCController. Options, best first:

  1. SDL player index → GCController.playerIndex (iOS/tvOS, and macOS pads on the MFi path). SDL3 assigns a free player index on every gamepad add (SDL_joystick.c:2144-2165). On Apple’s MFi driver, SetDevicePlayerIndex literally writes controller.playerIndex = player_index (SDL_mfijoystick.m:847-855). On iOS/tvOS our extension is statically linked into the same binary as Godot’s SDL, so it can call SDL_GetJoysticks() / SDL_GetJoystickGUIDForID() / SDL_GetJoystickPlayerIndexForID() directly. Steps: match the Godot GUID to the SDL instance, read the player index, then find [GCController controllers] with that playerIndex. Risk: SDL symbols in the static libgodot may be stripped or hidden. Check with nm on the export template. On macOS, Sineaggi confirmed Godot’s executable exports SDL symbols (they dlsym 556 of them). [Likely]
  2. Fallback heuristic: vendor/product from get_joy_info against GCController.vendorName/productCategory (SDL builds the joystick name from these: SDL_mfijoystick.m:311-316), plus connection order. Identical twin pads (two DualSenses) are ambiguous by GUID, so break ties by connection order. Sineaggi’s guide notes this limitation (“identical controllers share GUIDs; first match wins”).
  3. For HIDAPI-path pads on macOS (DualSense, BT Xbox), SDL does not touch GCController.playerIndex, so use option 2.

Also from Sineaggi (verified on hardware): GameController discovery is asynchronous (empty on the first frames, so re-query at use time and never cache a startup “no haptics”). Enumeration is empty in headless processes, so GUT cannot exercise it. Engine stoppedHandler/resetHandler fire off the main thread. Retain transient players. Tear down on GCControllerDidDisconnectNotification.

Localities [Certain from the Apple docs + SDL source]

Section titled “Localities [Certain from the Apple docs + SDL source]”

GCHapticsLocalityDefault (handles, “the expected experience”), Handles, LeftHandle, RightHandle, Triggers, LeftTrigger, RightTrigger, and All. Use Default/Handles for most effects, the per-hand localities for stereo (damage direction), and Triggers for Xbox fire feedback. Check supportedLocalities per controller; engine.supportsHaptics refers to the device, not the pad (the GCDeviceHaptics doc says so explicitly).

Device CoreHaptics path Transients / sharpness Notes
iPhone 8 and later (Taptic Engine) CHHapticEngine default init Full (transient + continuous, sharpness, curves, AHAP, audio) capabilitiesForHardware().supportsHaptics. [Certain]
iPad none (no Taptic Engine) — supportsHaptics == false; controllers only. [Likely]
DualSense (iOS 14.5+/tvOS/macOS 11.3+) GCDeviceHaptics Real: voice-coil actuators render crisp taps vs soft buzz, left/right isolated, identical over USB and BT Hardware-verified on macOS by Sineaggi. Same GC stack on iOS/tvOS [Likely]. Adaptive triggers are a separate API (GCDualSenseAdaptiveTrigger).
DualShock 4 GCDeviceHaptics Approximated on rumble motors (sharpness biases toward the smaller motor) Named supported in WWDC20 [Certain]. A DTS-acknowledged bug: CHHapticAdvancedPatternPlayer fails on DS4 (error -4811, forum 773615). Use plain CHHapticPatternPlayer on controllers. [Certain]
Xbox Wireless / Elite 2 / Series X|S GCDeviceHaptics incl. trigger localities (impulse triggers) Approximated on handle motors; trigger motors are independently addressable WWDC20 names Xbox + Elite 2 [Certain]. Series X|S trigger locality support [Likely].
Switch Pro / Joy-Con (iOS 16+) probably none — No evidence GC exposes HD Rumble; reports say rumble doesn’t work off-Nintendo hosts. [Guessing] Check controller.haptics != nil at runtime.
MFi phone-clip pads (Backbone, Kishi, etc.) usually none — Most have no actuators. [Likely] Runtime check covers it.
Siri Remote (all gens) none — No actuator. SDL also disables the remote as a joystick (SDL_HINT_TV_REMOTE_AS_JOYSTICK=0). [Certain for Godot config; Likely for hardware]
Apple TV box none — Controllers only. [Certain]
Mac built-in / Magic Trackpad NSHapticFeedbackManager only (3 patterns: generic, alignment, levelChange), and only felt while a finger is on the trackpad — The HIG limits it to drags/force-clicks. Useless for gameplay, so skip. [Certain]
Mac + controller GCDeviceHaptics as per controller Same code as iOS. [Certain]

From HIG: Playing haptics [Certain]:

  • Causality and consistency: one pattern means one thing. Never reuse a “failure” pattern for a success.
  • Harmony: match haptic intensity/sharpness to the animation and sound it accompanies. Sync with audio where possible.
  • Avoid overuse: “the best haptic experience is one that people may not be conscious of, but miss when it’s turned off.” For bullet-heaven density this means rate-limit and coalesce: kill-confirm ticks can’t fire per enemy.
  • Prefer short haptics for discrete events. Long continuous ones are for sustained gameplay flow (a charge, a boss rumble).
  • Make haptics optional. We already have the toggle (HapticsOutput.enabled). The iOS Accessibility → Touch → Vibration switch is a global kill that CoreHaptics obeys anyway [Likely]. Neither “System Haptics” nor the Ring/Silent switch is a documented gate for custom CoreHaptics [Guessing]. Our own toggle is the contract.
  • Sharpness vocabulary: low = “soft, rounded, organic”, high = “crisp, precise, mechanical”. Transient = tap/impulse, continuous = sustained vibration.
  • Battery/lifecycle (from the CoreHaptics docs plus Sineaggi): enable isAutoShutdownEnabled (stock Godot already does this for the phone engine), set playsHapticsOnly = true so the engine doesn’t touch our audio session, restart from resetHandler, and expect stoppedHandler on backgrounding or audio interruption. Continuous events cap at 30 s.
Section titled “Recommended shape (for the design spec, not decided here)”
  • New GDExtension addons/apple_haptics/ exposing roughly AppleHaptics.supports_device(), supports_joy(id), play(target, pattern_name), tap(target, intensity, sharpness, locality), buzz(...), stop(target). Use named patterns preloaded from AHAP JSON files in res:// (loaded via CHHapticPattern initWithDictionary:), so designers iterate on data rather than code. The same AHAP drives the phone and the pads. Apple says to “design once, play everywhere”.
  • HapticsOutput._joy / _handheld become backend-dispatch seams: Apple plugin when present and supports_*, else the current Input calls (Android, web, SDL-only pads).
  • Device test matrix: iPhone (Taptic), iPad + DualSense, Apple TV + DualSense / Xbox Series, Mac + DualSense (HIDAPI path) / wired Xbox (MFi path).
  1. Joypad-id → GCController mapping is heuristic (player index via SDL symbols, or name/order). Identical twin pads in 4-pilot co-op are the hard case, and it depends on SDL symbols being linkable from the static template.
  2. The tvOS fork build path: no godot-cpp tvOS toolchain, and GDExtension ABI parity between the fork (4.7.dev) and stock 4.7 is unverified. Each needs a spike.
  3. Controller-side CoreHaptics quirks: CHHapticAdvancedPatternPlayer is broken on at least DS4. Sharpness/transients are only real on DualSense, so on Xbox/DS4 a premium AHAP may feel like plain rumble. Mixing with SDL rumble is undocumented, so we must stop calling start_joy_vibration on Apple when the plugin is active.