Haptic Feedback Design Reference¶
This document is for game-system implementors wiring rumble events to the IInput haptic API. It is not a user-facing guide.
Interface summary¶
Five methods on IInput (platform/IInput.h) cover the complete haptic lifecycle:
| Method | Purpose |
|---|---|
supportsRumble(gamepadId) |
Check main-motor capability before calling rumble() |
supportsTriggerRumble(gamepadId) |
Check trigger-motor capability before calling rumbleTriggers() |
rumble(gamepadId, lowFreq, highFreq, durationMs) |
Fire main-motor vibration; low-freq targets the left motor (body vibration), high-freq the right (detail/texture) |
rumbleTriggers(gamepadId, leftRumble, rightRumble, durationMs) |
Fire per-trigger motor vibration (Xbox Elite, DualSense) |
stopRumble(gamepadId) |
Cancel all in-progress rumble — main motors and triggers — immediately |
All intensity values are normalised [0.0, 1.0]. The backend scales to hardware range.
Guidance for implementors¶
- Always call
supportsRumble/supportsTriggerRumblebefore firing effects; skip gracefully on hardware without motors. - Call
stopRumbleon pause, menu entry, and game exit — never leave a rumble running in a paused state. - Keep durations short on repetitive events (gun bursts) to avoid fatigue.
stopRumblesilences both main motors and triggers in one call; no need to stop them separately.
Event catalogue¶
Suggested haptic events for flight-sim game systems. Tuning values are starting points; adjust by playtesting.
| Event | Low-freq | High-freq | Trigger | Duration (ms) | Notes |
|---|---|---|---|---|---|
| Gun burst | 0.0 | 0.8 | — | 80 per burst | Short, high-freq pulse per trigger pull; repeat cadence matches fire rate |
| Missile launch | 0.6 | 0.6 | — | 150 | Single pulse, both motors |
| Missile warning | 0.7 | 0.0 | — | 3 × 50 ms bursts | Distinct from gun fire — left-only, pulsed; mirrors audio lock tone |
| Landing gear touchdown | 0.9 | 0.3 | — | 200 | Heavy low-freq on impact |
| Hit taken | 0.8 | 0.4 | — | 120 | Asymmetric if direction known: port hit → left motor heavier |
| Stall buffet | 0.3 | 0.1 | — | continuous | Sustain while AoA exceeds stall threshold; stop on recovery |
| Afterburner ignition | 0.4 | 0.2 | — | ramp 300 then sustain | Driven by EntityRenderEntry::abEngaged (set by FlightIntegrator when ctrl.afterburner commanded and aircraft has an ab_thrust table); replaces former throttle == 100 proxy |
| Engine failure (single) | 0.5 | 0.0 | — | continuous | Driven by EntityRenderEntry::engineFailFlags; kEngineFailLeft (bit 1) → left motor only; kEngineFailRight (bit 2) → right motor only; both or kEngineFailGeneric (bit 0) → symmetric; currently only kEngineFailGeneric is populated (from damageLevel ≥ Heavy) |
| G-LOC onset | 0.0 | 0.6 | — | continuous | Intensity proportional to G load above 6G; peak just before grey-out |
| Compressor stall | 0.6 | 0.0 | — | 4 × 30 ms irregular | Stutter pattern — uneven spacing distinguishes it from gun fire |
| GPWS / terrain warning | 0.5 | 0.5 | — | 2 × 100 ms | Distinct double-pulse; easily distinguished from the 3-pulse missile warning |
| Carrier trap | — | — | 0.9 / 0.9 | 300 | Trigger-motor pull on arrestor-wire engagement; use rumbleTriggers |
| Hydraulic failure | 0.2 | 0.0 | — | continuous on input | Low continuous rumble whenever a control surface is deflected |
| Transonic buffet | 0.3 | 0.3 | — | 400 | Brief oscillation at Mach 0.85–1.05 transition |
| Bomb / ordinance release | 0.4 | 0.0 | — | 80 | Single low-freq thud per store dropped |
Platform notes¶
- Windows: SDL3 XInput/DirectInput handles capability detection. Xbox controllers work over Bluetooth or USB without extra setup.
- macOS: SDL3 uses the GameController framework transparently. MFi and DualSense controllers report rumble support correctly.
- Linux: Rumble requires xpadneo + hidraw udev rules and
inputgroup membership.supportsRumbleaccuracy depends on whether xpadneo is active; without it the stockhid_microsoftdriver connects the hardware but SDL3 may correctly report no rumble capability. See linux-gamepad.md.
Lua scripting (#128)¶
Mod authors writing custom missions or AI behaviours can trigger haptic feedback from Lua. The binding
lives in the engine's Lua runtime (LuaController) and is deliberately separate from IInput:
IInputis a platform HAL; Lua scripts never call it directly.- The Lua-facing API abstracts
gamepadIdaway — it always targets the current player's gamepad. Because a mission script runs server-side, the call is routed to clients (a reliableMsgHaptic), and each client plays it on its own local gamepad (id 0). There is no gamepad id in the Lua API. - Sandbox guards live in the engine binding: intensities clamp to
[0, 1]and a single request is capped at 5000 ms, so an untrusted mod cannot latch rumble on.stop_rumble()is always available.
The bindings are plain globals (not under world.*):
rumble(low_freq, high_freq, duration_ms) -- both motors; low = heavy, high = buzz
rumble_triggers(left, right, duration_ms) -- impulse triggers (Xbox One / Series pads)
stop_rumble() -- cancel all rumble immediately
Routing: LuaController → the host WorldApi seam (engine/script/WorldApi.h) → fl-server
broadcasts MsgHaptic (0x17) → the client plays it via IInput on gamepad 0. On a headless server
with no clients (or a null-input mock), the whole path is a clean no-op. Full API reference:
docs/modding/ai.md.
Force feedback (FFB sticks, #928)¶
Distinct from gamepad rumble: an FFB HOTAS stick renders directional canned effects through the
SDL3 haptic API (SDL_INIT_HAPTIC + SDL_OpenHapticFromJoystick). This is a cueing layer, not a
control-loading sim — the effects are canned buffet/rumble/kick, not modelled stick forces.
The surface is on IJoystick as non-pure, no-op-default virtuals (a gamepad-only backend or a
non-FFB stick simply reports no capability, so nothing changes for them):
| Method | Effect |
|---|---|
supportsForceFeedback(joy) |
true when the stick opened a haptic device |
playFfbEffect(joy, slot, FfbEffect) |
play / update in place if the slot holds the same kind |
stopFfbEffect(joy, slot) |
stop one slot |
stopAllFfbEffects(joy) |
stop all (called on pause / focus loss) |
FfbEffect = {kind: ConstantForce | Sine, directionDeg, magnitude [0,1], periodMs, durationMs};
kFfbSlotCount = 4. HapticController (device 0) drives three slots:
| Slot | Effect | Trigger |
|---|---|---|
| 0 | Stall buffet (Sine, ~55 ms) | AoA past the stall angle; amplitude grows with AoA; stops on recovery |
| 1 | Ground roll (Sine, ~18 ms) | on the deck (AGL < 3 m) rolling faster than 2 m/s; amplitude scales with speed |
| 2 | Gun-fire kick (ConstantForce, 60 ms) | one-shot per weaponFired frame |
Non-goals (deliberately out of scope): trim forces, spring/centering, and any modelled control
loading. Config: [controls] ffb_enabled (default true) + ffb_strength (0–1). Capability-guarded —
a clean no-op without an FFB device. Covered by tests/test_haptic_controller.cpp (a TrackingJoystick
recording effect calls).