NiceVibrations Haptics Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fire mobile haptic feedback (NiceVibrations presets) on ~24 core player-facing beats, gated by the existing settings vibration toggle.
Architecture: One new static helper ShadowRaiders.Presentation.Haptics (mirrors the Floaters/UiFeedback seam pattern) maps a semantic HapticEvent enum to Lofelt.NiceVibrations.HapticPatterns presets in a single in-code table; call sites sprinkle one-line Haptics.Play(...) calls beside existing screen-shake/floater beats. A RuntimeInitializeOnLoadMethod bootstraps the required singleton HapticReceiver component — no scene or prefab edits, no project-settings changes.
Tech Stack: Unity 6 / URP, NiceVibrations 4.x (Lofelt, under Assets/ExternalAssets/Feel/NiceVibrations/), default game assembly (auto-references Lofelt.NiceVibrations asmdef — no asmdef work).
Spec: docs/superpowers/specs/2026-07-09-nicevibrations-haptics-design.md
Global Constraints¶
- No test suite exists in this project (repo convention: verify by running the game). Each task's verify step = Unity compiles clean (check via
mcp__unity-mcp__Unity_GetConsoleLogsafter Unity picks up the edits) — no NUnit tests. Final task adds a play-mode smoke check. - Never edit scripts while the editor is in play mode (breaks the MCP wrapper — see project memory).
- Do not modify
GameConfig.cs,GameConfig.asset, any.unityscene, any prefab, or anything underProjectSettings/— this feature deliberately needs none of them. - Do not touch the frozen web prototype (
game.js,config.js). - New code follows surrounding style: terse, comment only non-obvious constraints, English identifiers.
- Player-only rule: haptics fire only for events affecting the local player. Every call site below is already player-gated by construction — do not add haptics to rival/AI paths (
Chest.MarkRivalLooted,Portal.Call("rival"),RaiderController, AI-vs-AI damage). - Commit after each task (working tree already has unrelated staged-ish changes —
git addonly the files each task names).
Task 1: Haptics static helper + HapticReceiver bootstrap¶
Files:
- Create: unity/raiders/Assets/Scripts/Presentation/Haptics.cs
Interfaces:
- Consumes: Lofelt.NiceVibrations.HapticPatterns.PlayPreset(PresetType), Lofelt.NiceVibrations.HapticReceiver, ShadowRaiders.UI.Common.GameSettings.Vibration (static bool, already persisted/toggled end-to-end).
- Produces: enum ShadowRaiders.Presentation.HapticEvent (26 members, exact list below) and static void ShadowRaiders.Presentation.Haptics.Play(HapticEvent e) — every later task calls exactly this.
- [ ] Step 1: Write the file
using Lofelt.NiceVibrations;
using UnityEngine;
namespace ShadowRaiders.Presentation
{
/// <summary>Semantic haptic beats. Call sites name the moment; the preset mapping lives in one
/// table in <see cref="Haptics"/> so feel is retuned in a single place.</summary>
public enum HapticEvent
{
UiTap, UiConfirmArm, // UI
PlayerHit, LowHp, Death, // survival
MeleeHit, Crit, Kill, BackstabKill, BossDown, // combat
ChestOpen, ChestLegendary, GateOpen, // interaction
PortalCalled, PortalOpen, Extracted, KeyPickup, // extraction
LootCommon, LootGear, LootRare, // loot pickup
Dodge, HealDone, // movement / recovery
StormWarn, StormBreak, // world alerts
Purchase, Equip, // meta screens
}
/// <summary>
/// Global haptics trigger mirroring the <see cref="Floaters"/> / <c>UiFeedback</c> pattern — callable from
/// anywhere without DI. Wraps NiceVibrations' <see cref="HapticPatterns"/> presets behind semantic events,
/// gated on the persisted Settings vibration toggle (<see cref="ShadowRaiders.UI.Common.GameSettings"/>).
/// Editor playback is a safe no-op (rumbles a gamepad if one is connected).
/// </summary>
public static class Haptics
{
// tiny ticks (Selection) self-throttle so a vacuumed coin pile doesn't mush into one long buzz;
// bigger beats always play — PlayPreset replaces whatever is currently buzzing anyway.
const float MinGap = 0.06f;
static float _lastFire = -10f;
public static void Play(HapticEvent e)
{
if (!ShadowRaiders.UI.Common.GameSettings.Vibration) return;
var preset = PresetFor(e);
float now = Time.unscaledTime; // unscaled: hitstop zeroes timeScale right when beats fire
if (preset == HapticPatterns.PresetType.Selection && now - _lastFire < MinGap) return;
_lastFire = now;
HapticPatterns.PlayPreset(preset);
}
/// <summary>The ONE event→preset table (retune feel here).</summary>
static HapticPatterns.PresetType PresetFor(HapticEvent e)
{
switch (e)
{
case HapticEvent.UiTap: return HapticPatterns.PresetType.Selection;
case HapticEvent.UiConfirmArm: return HapticPatterns.PresetType.MediumImpact;
case HapticEvent.PlayerHit: return HapticPatterns.PresetType.MediumImpact;
case HapticEvent.LowHp: return HapticPatterns.PresetType.Warning;
case HapticEvent.Death: return HapticPatterns.PresetType.Failure;
case HapticEvent.MeleeHit: return HapticPatterns.PresetType.LightImpact;
case HapticEvent.Crit: return HapticPatterns.PresetType.RigidImpact;
case HapticEvent.Kill: return HapticPatterns.PresetType.RigidImpact;
case HapticEvent.BackstabKill: return HapticPatterns.PresetType.HeavyImpact;
case HapticEvent.BossDown: return HapticPatterns.PresetType.Success;
case HapticEvent.ChestOpen: return HapticPatterns.PresetType.MediumImpact;
case HapticEvent.ChestLegendary: return HapticPatterns.PresetType.HeavyImpact;
case HapticEvent.GateOpen: return HapticPatterns.PresetType.HeavyImpact;
case HapticEvent.PortalCalled: return HapticPatterns.PresetType.LightImpact;
case HapticEvent.PortalOpen: return HapticPatterns.PresetType.MediumImpact;
case HapticEvent.Extracted: return HapticPatterns.PresetType.Success;
case HapticEvent.KeyPickup: return HapticPatterns.PresetType.Success;
case HapticEvent.LootCommon: return HapticPatterns.PresetType.Selection;
case HapticEvent.LootGear: return HapticPatterns.PresetType.LightImpact;
case HapticEvent.LootRare: return HapticPatterns.PresetType.MediumImpact;
case HapticEvent.Dodge: return HapticPatterns.PresetType.LightImpact;
case HapticEvent.HealDone: return HapticPatterns.PresetType.Success;
case HapticEvent.StormWarn: return HapticPatterns.PresetType.Warning;
case HapticEvent.StormBreak: return HapticPatterns.PresetType.HeavyImpact;
case HapticEvent.Purchase: return HapticPatterns.PresetType.Success;
case HapticEvent.Equip: return HapticPatterns.PresetType.LightImpact;
default: return HapticPatterns.PresetType.LightImpact;
}
}
// NiceVibrations wants exactly one HapticReceiver alive (app-focus interruption + global level,
// like an AudioListener). Bootstrapped from code so no scene/prefab needs editing.
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
static void EnsureReceiver()
{
if (Object.FindFirstObjectByType<HapticReceiver>() != null) return;
var go = new GameObject("HapticReceiver");
Object.DontDestroyOnLoad(go);
go.AddComponent<HapticReceiver>();
}
}
}
Note: GameSettings.Vibration is read fully-qualified per call (not cached, no OnVibration subscription) — the toggle takes effect immediately and there is no init-order coupling. GameSettings.OnVibration stays unused, as the spec says.
- [ ] Step 2: Verify compile
Focus/open the Unity editor so it imports, then run mcp__unity-mcp__Unity_GetConsoleLogs (errors only).
Expected: no compile errors mentioning Haptics.cs / Lofelt.
- [ ] Step 3: Commit
git add unity/raiders/Assets/Scripts/Presentation/Haptics.cs unity/raiders/Assets/Scripts/Presentation/Haptics.cs.meta
git commit -m "Haptics: static NiceVibrations helper + auto-bootstrapped HapticReceiver"
(The .meta appears after Unity imports — if it doesn't exist yet, let the editor regain focus first.)
Task 2: UI call sites — button taps, confirm-arm, equip, level-up purchase¶
Files:
- Modify: unity/raiders/Assets/Scripts/UI/Common/UIButton.cs (method OnClick, ~line 89)
- Modify: unity/raiders/Assets/Scripts/UI/Inventory/InventoryController.cs (methods DoEquip ~306, DoLevelUp ~366)
Interfaces:
- Consumes: ShadowRaiders.Presentation.Haptics.Play(HapticEvent) from Task 1 — events UiTap, UiConfirmArm, Equip, Purchase.
- Produces: nothing consumed by later tasks.
- [ ] Step 1: UIButton.OnClick
Add using ShadowRaiders.Presentation; to the file's using block. Replace the body of OnClick:
void OnClick()
{
UiFeedback.PlayClick(clickSound); // no-op by default
// a click on any OTHER button cancels a pending confirm elsewhere ("different click" resets)
if (s_pending != null && s_pending != this) s_pending.ResetConfirm();
if (_confirmRequired && !_awaiting) { Haptics.Play(HapticEvent.UiConfirmArm); ArmConfirm(); return; } // first click → arm, don't fire yet
Haptics.Play(HapticEvent.UiTap);
ResetConfirm();
Clicked?.Invoke();
}
(The arm branch gets its own heavier buzz instead of the tap tick — an armed "Confirm?" should feel different.)
- [ ] Step 2: InventoryController — equip + purchase beats
Add using ShadowRaiders.Presentation; if not present. In DoEquip(), directly after _save.Save(); (~line 321):
_save.Save();
Haptics.Play(HapticEvent.Equip);
In DoLevelUp(), directly after the spend line d.blueprints -= cost; sel.level++; (~line 375):
d.blueprints -= cost; sel.level++;
Haptics.Play(HapticEvent.Purchase);
Do NOT add haptics to DoScrap/DoUnequip (out of core scope) or to the d.blueprints < cost early-return (denial buzz is optional-tier, excluded).
- [ ] Step 3: Verify compile
mcp__unity-mcp__Unity_GetConsoleLogs — expected: no errors.
- [ ] Step 4: Commit
git add unity/raiders/Assets/Scripts/UI/Common/UIButton.cs unity/raiders/Assets/Scripts/UI/Inventory/InventoryController.cs
git commit -m "Haptics: UI beats — tap, confirm-arm, equip, level-up purchase"
Task 3: PlayerController — hit taken, low-HP latch, death, melee, crit, backstab kill, dodge, heal¶
Files:
- Modify: unity/raiders/Assets/Scripts/World/PlayerController.cs (melee block ~366-397, heal-complete block ~265-275, Dodge ~581-595, OnDamaged ~619-625, OnDied ~747-754, plus two new private members)
Interfaces:
- Consumes: Task 1's Haptics.Play — events MeleeHit, Crit, BackstabKill, PlayerHit, LowHp, Death, Dodge, HealDone. This file fully qualifies ShadowRaiders.Presentation.* (matching its existing Floaters calls) — do NOT add a using.
- Produces: nothing consumed by later tasks. (Task 4's Kill suppression relies on Health.LastHitBackstab, which already exists.)
- [ ] Step 1: Add the low-HP latch members
Next to the other private fields (e.g. near _dead):
const float LowHpFrac = 0.3f; // mirrors PlayerVitalsView.lowHpFrac — the bar-turns-red threshold
bool _lowHpBuzzed; // one Warning buzz per dip under LowHpFrac; re-armed by a heal back above
- [ ] Step 2: Melee strike beats (~line 366-397)
Directly BEFORE the fh.TakeDamage(...) call (~line 373) — before, so a killing blow's Kill/BackstabKill beat lands last and wins the buzz:
if (!backWide) ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.MeleeHit);
fh.TakeDamage(dmg, transform, skipPowCheck: backWide, backstab: backWide); // (existing line, unchanged)
Inside the if (backWide) block: in the KILL branch (if (!fh.Alive), after _raid.AddShake(_cfg.shake.silentKill); ~line 386):
ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.BackstabKill);
…and in the survived-crit else branch, beside the existing "CRITICAL" floater (~line 394):
ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.Crit);
- [ ] Step 3: OnDamaged — hit taken + low-HP crossing (~line 619)
Replace the method with:
void OnDamaged(float amount)
{
_raid.PlayerLastHitTime = Time.time; // web p.noHitT reset — gates extraction board-stall + HP regen
_raid.AddShake(_cfg.shake.playerHit); // web: a hit on the player is a bigger jolt than a generic hit
if (_health.Alive) // a lethal hit buzzes via OnDied's Death beat instead
{
if (_health.Fraction <= LowHpFrac && !_lowHpBuzzed)
{ _lowHpBuzzed = true; ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.LowHp); }
else ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.PlayerHit);
}
if (_healT >= 0f) { _healT = -1f; HealInterrupted(); } // taking a hit breaks the heal channel (web floater 1513)
if (_view != null) _view.HurtFlash(0.25f, new Color(1f, 0.541f, 0.541f)); // web hurtFlash player tint #ff8a8a
}
- [ ] Step 4: Heal-complete beat + latch re-arm (~line 265-275)
In the heal-channel completion block, after _raid.DidHeal = true; (~line 274):
_raid.DidHeal = true; // web G.tutorial.healed — the heal-channel completed
if (_health.Fraction > LowHpFrac) _lowHpBuzzed = false; // healed clear → re-arm the low-HP warning
ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.HealDone);
- [ ] Step 5: Dodge beat (~line 594)
In Dodge(...), directly before the final return true;:
ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.Dodge);
return true;
(All the early return false paths stay silent — dodge-fail buzz is excluded.)
- [ ] Step 6: Death beat (~line 747)
In OnDied(), after _dead = true;:
_dead = true;
ShadowRaiders.Presentation.Haptics.Play(ShadowRaiders.Presentation.HapticEvent.Death);
- [ ] Step 7: Verify compile
mcp__unity-mcp__Unity_GetConsoleLogs — expected: no errors.
- [ ] Step 8: Commit
git add unity/raiders/Assets/Scripts/World/PlayerController.cs
git commit -m "Haptics: player beats — hit/low-HP/death, melee/crit/backstab-kill, dodge, heal"
Task 4: World call sites — kills, chest, gate, portal, key, loot, storm¶
Files:
- Modify: unity/raiders/Assets/Scripts/World/EnemyController.cs (OnDied ~937-968)
- Modify: unity/raiders/Assets/Scripts/World/Chest.cs (Open ~154-167)
- Modify: unity/raiders/Assets/Scripts/World/Gate.cs (Open ~86-93)
- Modify: unity/raiders/Assets/Scripts/World/Portal.cs (Call ~120-133, TickIncoming ~140-145, TickOpen ~177)
- Modify: unity/raiders/Assets/Scripts/World/KeyPickup.cs (~84-86)
- Modify: unity/raiders/Assets/Scripts/World/LootService.cs (Collect ~132-146)
- Modify: unity/raiders/Assets/Scripts/World/RaidController.cs (TickWorldEvents ~78-80)
These files (except possibly LootService) already have using ShadowRaiders.Presentation; (they call Floaters unqualified) — add the using only where actually missing; also add using for ShadowRaiders.Loot types only if the file doesn't already reference them the same way.
Interfaces:
- Consumes: Task 1's Haptics.Play — events Kill, BossDown, ChestOpen, ChestLegendary, GateOpen, PortalCalled, PortalOpen, Extracted, KeyPickup, LootCommon, LootGear, LootRare, StormWarn, StormBreak. Also Health.LastHitBackstab (existing) to suppress the Kill beat when Task 3's heavier BackstabKill beat covers the same frame.
- Produces: nothing consumed by later tasks.
- [ ] Step 1: EnemyController — player-attributed kill + boss down
In OnDied() (~line 942), turn the kill-tally if into a block and add the beat, suppressed for backstab killing blows (PlayerController fires the heavier BackstabKill for those — avoid a same-frame double-buzz):
if (_raid != null && _raid.Player != null && _health != null && _health.LastAttacker != null &&
(_health.LastAttacker == _raid.Player || _health.LastAttacker.IsChildOf(_raid.Player)))
{
_raid.Kills++; // player-attributed kill tally for the result screen (web G.kills)
if (!_health.LastHitBackstab) Haptics.Play(HapticEvent.Kill); // backstab kills buzz heavier from PlayerController
}
In the if (stageBoss) block, after the _raid.AddShake(...) line (~964):
Haptics.Play(HapticEvent.BossDown);
- [ ] Step 2: Chest.Open — rarity-scaled pop
After the existing _raid.AddShake(...) line (~165), mirroring its rarity branch:
Haptics.Play(rarity == ShadowRaiders.Loot.LootRarity.Legendary ? HapticEvent.ChestLegendary : HapticEvent.ChestOpen);
(Only Open() — never MarkRivalLooted().)
- [ ] Step 3: Gate.Open
After _raid.AddShake(_cfg.shake.gateOpen, _gate.World); (~90):
Haptics.Play(HapticEvent.GateOpen);
(Only the player-channel Open(); the rival path flips _gate.Open directly and never calls this.)
- [ ] Step 4: Portal — called / open / extracted
In Call(string by) after the banner if/else chain (~line 132), player summons only; an instant-open portal skips INCOMING so it gets the open beat directly:
if (by != "rival") Haptics.Play(instant ? HapticEvent.PortalOpen : HapticEvent.PortalCalled);
In TickIncoming where the phase flips to Open, after _raid.AddShake(_cfg.shake.portalOpen, transform.position); (~143):
Haptics.Play(HapticEvent.PortalOpen);
In TickOpen, the boarding completion line (~177) — beat BEFORE ending the raid:
if (Prog >= 1f) { Haptics.Play(HapticEvent.Extracted); _raidCtl.EndRaid(extracted: true, descended: IsProgress); return; } // progress = descend a stage
- [ ] Step 5: KeyPickup
Beside the acquire floater (~85):
_raid.PlayerHasKey = true;
Haptics.Play(HapticEvent.KeyPickup);
Floaters.Text(transform.position, "Key acquired \U0001F511", Floaters.S.keyEvent); // web "Key acquired 🔑"
- [ ] Step 6: LootService.Collect — per-type pickup tick
After the switch (it.type) bank block (i.e. right after its closing brace, ~line 146), before the floater block:
// pickup buzz by weight: currency = tiny tick (self-throttled), consumables/gear = light, epic+ gear = medium
if (it.type == LootType.Coin || it.type == LootType.Blueprint) Haptics.Play(HapticEvent.LootCommon);
else if (it.type != LootType.Consumable && it.rarity >= ShadowRaiders.Loot.LootRarity.Epic) Haptics.Play(HapticEvent.LootRare);
else Haptics.Play(HapticEvent.LootGear);
(Match the file's existing qualification style for LootType/LootRarity — it already uses both in this method; LootRarity numeric order is Common=0 < Rare=1 < Epic=2 < Legendary=3, so >= Epic is correct.)
- [ ] Step 7: RaidController — storm beats
Extend the two one-shot storm lines (~79-80):
if (!_storm2Fired && rem <= ev.storm2At) { _storm2Fired = true; Floaters.ShowBanner("The storm draws near — make for an exit!", BannerStyle.Danger, 4.4f); Haptics.Play(HapticEvent.StormWarn); }
if (!_storm3Fired && rem <= ev.stormAt) { _storm3Fired = true; Floaters.ShowBanner("⚡ THE WILDMAGIC SWALLOWS THORNWOOD ⚡", BannerStyle.Danger, 4.4f); _raid.AddShake(_cfg.shake.storm); Haptics.Play(HapticEvent.StormBreak); }
(storm1/stir stay haptic-free per spec; the storm HP Drain is silent by design.)
- [ ] Step 8: Verify compile
mcp__unity-mcp__Unity_GetConsoleLogs — expected: no errors.
- [ ] Step 9: Commit
git add unity/raiders/Assets/Scripts/World/EnemyController.cs unity/raiders/Assets/Scripts/World/Chest.cs unity/raiders/Assets/Scripts/World/Gate.cs unity/raiders/Assets/Scripts/World/Portal.cs unity/raiders/Assets/Scripts/World/KeyPickup.cs unity/raiders/Assets/Scripts/World/LootService.cs unity/raiders/Assets/Scripts/World/RaidController.cs
git commit -m "Haptics: world beats — kills, chest/gate/portal/key, loot pickup, storm"
Task 5: Play-mode smoke verification¶
Files: none modified.
Interfaces: consumes everything above; produces the go/no-go verdict.
- [ ] Step 1: Clean console + enter play mode
Via unity-mcp: clear/read console, enter play mode (remember: no script edits while playing). Play into a raid.
- [ ] Step 2: Exercise beats, watch for exceptions
In the editor there is no phone motor — success = zero Haptics/Lofelt/HapticPatterns exceptions while triggering: a few UI buttons (lobby, settings), start raid, melee a mob to death (Kill beat path), take a hit, dodge, collect spilled loot (coin + gear), open a chest, drink a heal. Check Unity_GetConsoleLogs for errors after.
- [ ] Step 3: Toggle gate check
Settings → vibration OFF → repeat a couple of beats → still no exceptions (calls early-out). Toggle back ON.
- [ ] Step 4: Confirm the receiver bootstrap
While in play mode, verify a HapticReceiver GameObject exists (DontDestroyOnLoad section of the hierarchy) — via Unity_RunCommand object query or a hierarchy screenshot.
- [ ] Step 5: Report
No commit (nothing changed). Report results; on-device Android feel-check is the user's playtest.
Out of scope (per approved spec — do not implement)¶
Optional alert tier (spotted/afflicted/stamina/heal-interrupt/denials), per-shot ranged fire, PlayConstant boarding ramp, rival-raider kill beats (RaiderController), win/lose-card haptics, GameConfig haptics group, any settings-UI work (already exists).