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 isSineaggi/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.gdextensionfile can cover all three platforms: stock 4.7 iOS and the tvOS fork share the sameEditorExportPlatformAppleEmbeddedGDExtension 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]
2. Plugin mechanism per platform
Section titled “2. Plugin mechanism per platform”iOS (stock 4.7)
Section titled “iOS (stock 4.7)”- Both mechanisms exist. [Certain]
- Legacy
.gdipplugins inres://ios/plugins/: a static.a/.xcframeworkwithinitialization/deinitializationsymbols, 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.hhandles.a/.xcframeworklibraries for anyapple_embeddedtarget. It generates a$ENTRY_initstub that callsregister_dynamic_symbol()andadd_apple_embedded_platform_init_callback(), and adds-Wl,-U,_$ENTRY. The extension is statically linked into the app binary, which is how iOS forbidsdlopenfrom being a problem.
- Legacy
- We already ship a GDExtension on iOS:
platform/tvos/addons/sentry/sentry.gdextensionusesios.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 ofEngine.get_singleton(). A.gdipis 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]
tvOS (fork)
Section titled “tvOS (fork)”platform/tvos/export/export_plugin.h:EditorExportPlatformTVOS : EditorExportPlatformAppleEmbedded. Platform features areapple_embedded+tvos, so both.gdipplugins (fromres://tvos/plugins/,get_plugins(p_platform_name)) and static GDExtensions (the sharedgdextension_export_plugin.hbranch keyed onapple_embedded) work. Runtimeregister_dynamic_symbollives in the shareddrivers/apple_embedded/os_apple_embedded.mm. [Certain, from source].gdextensionkeys:tvos.release = "res://addons/hx/bin/libhx.xcframework"(thetvosfeature tag). [Likely; the feature lookup uses the platform features above]- Build-side gap: godot-cpp’s SCons tools have
ios.py(iphoneos/iphonesimulator) andmacos.pybut no tvOS target (godot-cpp/tools). Build the tvOS slice with godot-cpp’s CMake (CMAKE_SYSTEM_NAME=tvOS,-mappletvos-version-min=26.0to match the fork’sget_minimum_deployment_target() == "26.0"), or patchios.pyto take anappletvosSDK. 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 stableextension_api.jsonwithcompatibility_minimum = "4.7". Verify the fork’s GDExtension interface matches by dumping--dump-extension-apifrom the fork and diffing. [Guessing that it matches; must verify]
macOS (stock 4.7)
Section titled “macOS (stock 4.7)”- 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 insideContents/Frameworksand signed with the app’s identity. [Certain for the mechanism; Likely for the signing details]
One extension for all three?
Section titled “One extension for all three?”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.xcframeworkwith slices ios-arm64, ios-arm64-simulator and tvos-arm64 (static), andlibhx.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.supportedLocalitiesonly to setCAP_RUMBLE(Handles) andCAP_TRIGGER_RUMBLE(Triggers) (lines ~932-937). - Lazily, on the first
SDL_RumbleJoystick/SDL_RumbleJoystickTriggerscall (lines 1409-1455), it creates up to fourCHHapticEngines viacreateEngineWithLocality:(LeftHandle= low-frequency motor,RightHandle= high-frequency,LeftTrigger,RightTrigger). Each plays one infiniteHapticContinuousevent at intensity 1.0, and amplitude is driven byCHHapticDynamicParameterIDHapticIntensityControl. No sharpness, no transients. Engines are torn down on close. - Godot’s
JoypadSDLmapsInput.start_joy_vibration(device, weak, strong, dur)toSDL_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:353says 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:
- 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,SetDevicePlayerIndexliterally writescontroller.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 callSDL_GetJoysticks()/SDL_GetJoystickGUIDForID()/SDL_GetJoystickPlayerIndexForID()directly. Steps: match the Godot GUID to the SDL instance, read the player index, then find[GCController controllers]with thatplayerIndex. Risk: SDL symbols in the staticlibgodotmay be stripped or hidden. Check withnmon the export template. On macOS, Sineaggi confirmed Godot’s executable exports SDL symbols (theydlsym556 of them). [Likely] - Fallback heuristic: vendor/product from
get_joy_infoagainstGCController.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”). - 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).
4. Hardware support matrix
Section titled “4. Hardware support matrix”| 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] |
5. Apple HIG for game haptics: key points
Section titled “5. Apple HIG for game haptics: key points”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), setplaysHapticsOnly = trueso the engine doesn’t touch our audio session, restart fromresetHandler, and expectstoppedHandleron backgrounding or audio interruption. Continuous events cap at 30 s.
Recommended shape (for the design spec, not decided here)
Section titled “Recommended shape (for the design spec, not decided here)”- New GDExtension
addons/apple_haptics/exposing roughlyAppleHaptics.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 inres://(loaded viaCHHapticPattern 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/_handheldbecome backend-dispatch seams: Apple plugin when present andsupports_*, else the currentInputcalls (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).
Biggest risks
Section titled “Biggest risks”- 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.
- 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.
- Controller-side CoreHaptics quirks:
CHHapticAdvancedPatternPlayeris 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 callingstart_joy_vibrationon Apple when the plugin is active.
Sources
Section titled “Sources”- Local:
~/Claude/godot-tvos/godot(thirdparty/sdlv3.2.28:joystick/apple/SDL_mfijoystick.m,joystick/hidapi/SDL_hidapi_xboxone.c,joystick/SDL_joystick.c;drivers/sdl/joypad_sdl.cpp,drivers/sdl/SDL_build_config_private.h,drivers/apple_embedded/{apple_embedded,os_apple_embedded}.mm,editor/export/gdextension_export_plugin.h,editor/export/editor_export_platform_apple_embedded.h,platform/tvos/export/export_plugin.h); this repo’splatform/tvos/addons/sentry/sentry.gdextension,input/haptics_output.gd. - Apple HIG: Playing haptics
- GCDeviceHaptics, createEngine(withLocality:), GCHapticsLocality, Playing Haptics on Game Controllers (sample), GCDualSenseGamepad
- WWDC20 10614: Advancements in Game Controllers
- Apple forum 773615: CHHapticAdvancedPatternPlayer not working with GCController
- Sineaggi/godot-dualsense + its
docs/dualsense-porting-guide.md - kyoz/godot-haptics, toniqat/godot-haptics, GhostWalker562 module, extrawurst/godot-ios-impact-plugin, theisegoria/tactile, Lelisvaldo/godot-dualsense-native, timoschwarzer/godot-audio-haptics
- Godot docs: Creating iOS plugins; Godot PRs #114316, #111682, #114642, issue #122549
- godot-cpp tools/ (no tvOS)