Fluffer by MotorbunnyDevelopers

Web Launcher SDK

Haptics

The 14 haptic primitives and 25 recipes in @fluffer/game-kit, with strength scale, cancellation, rate limits and best practices.

Haptics are what make a Fluffer app feel like a Fluffer app. Instead of writing intensity arrays by hand, @fluffer/game-kit gives you 14 named primitives and 25 recipes tuned for game feel. Think of them as sound design for touch: a pulse is a snare hit, a heartbeat is a kick drum, an idle buzz is room tone.

import { createGame, haptics, recipes } from '@fluffer/game-kit';

const game = await createGame({ capabilities: ['device.vibrate'] });

await haptics.impact(0.9);          // sharp hit
await recipes.pickup();             // two rising taps
const stop = haptics.heartbeat(120);
// ...later
stop();

createGame() connects haptics to your game for you: it tracks the connected toy, respects pause, and applies the rate limit.

#Strength is 0 to 1

Every haptic function takes strength as a number from 0 to 1, like audio gain or alpha. The kit converts it to the toy's 0-200 scale and clamps out-of-range values, so 5 becomes full strength and -1 becomes off. game-kit primitives drive all motors together at the same strength.

#The 14 primitives

#Discrete feedback

CallReturnsUse it for
tap(strength = 0.4)Promise<void>About 30 ms. Menu navigation, button hover, small confirmations.
pulse(strength, durationMs = 100)Promise<void>A single buzz of any length.
impact(strength = 0.85)Promise<void>About 80 ms, sharp. Collisions, hits, dashes.
game.onPointer((e) => {
  if (e.type === 'down') void haptics.tap();
});
onCollision(() => haptics.impact(0.9));
onMessage(() => haptics.pulse(0.5, 120));

#Outcomes

CallReturnsUse it for
success()Promise<void>Two rising pulses. Level-up, achievement, win.
fail()Promise<void>One long falling buzz (about 400 ms). Game over, miss.
streak(count, base = 0.4)Promise<void>count pulses, each stronger than the last. Combos.
onLevelUp(() => haptics.success());
onGameOver(() => haptics.fail());
onCombo((n) => haptics.streak(n));      // a 3-streak feels different from an 8-streak

success() and fail() are deliberately the same in every Fluffer app, so players learn them. Use them as they are rather than redefining them.

#Continuous

CallReturnsUse it for
heartbeat(bpm, strength = 0.6)stop functionRhythmic pulses at a tempo. Tension, low health, suspense.
idle(strength = 0.15)stop functionA low steady hum. Engine running, ambient presence.
wave({ frequencyHz, amplitude?, durationMs, samplesPerCycle? })Promise<void>Smooth sine-wave oscillation. Alarms, energy beams. amplitude defaults to 1.
const stopBeat = haptics.heartbeat(140, 0.7);
onPlayerSafe(() => stopBeat());

const stopIdle = haptics.idle(0.12);
onEngineOff(() => stopIdle());

await haptics.wave({ frequencyHz: 4, amplitude: 1, durationMs: 800 });

#Curves

CallReturnsUse it for
rampUp({ from, to, durationMs, steps? })Promise<void>Linear rise. Charging, building tension.
rampDown({ from, to, durationMs, steps? })Promise<void>Linear fall. Cooldown, recovery.
fade(durationMs = 300, fromStrength = 0.5)Promise<void>Smooth tail-off to zero.
await haptics.rampUp({ from: 0, to: 1, durationMs: 1500 });
await haptics.rampDown({ from: 1, to: 0, durationMs: 800 });
void haptics.fade(400);

#Control

CallReturnsUse it for
stop()voidStops immediately and cancels every running pattern.
rotate(speed)Promise<void>Drives the rotation motor (0 to 1). Does nothing on toys without one. Needs device.rotate.
stopRotation()Promise<void>Stops rotation.
haptics.stop();
await haptics.rotate(0.6);      // e.g. MBBunny
await haptics.stopRotation();

#Recipes

Recipes are ready-made compositions of the primitives for moments most games have. One-shot recipes return Promise<void>. Continuous recipes (lowHealth, burn) return a stop function.

import { recipes } from '@fluffer/game-kit';

await recipes.collision();
await recipes.levelUp();

const stopLowHealth = recipes.lowHealth(140);   // faster bpm = more urgent
// ...when health is restored:
stopLowHealth();
CategoryRecipeUse it for
Combat and impactcollision()Hitting a wall, enemy or obstacle
shoot()Firing a weapon or projectile
block()Blocking an attack
parry()A perfectly timed deflect
damage()Taking damage
explosion()Bombs and blasts
Pickups and rewardspickup()Coins, items, power-ups
heal()Healing, regeneration
buff()Activating a power-up
levelUp()Level-up, stage clear
victory()Victory screen
Health and statuslowHealth(bpm = 110)Low health or stress. Returns a stop function.
shieldBreak()A shield or barrier shatters
freeze()Freeze, stun
burn()On fire, damage over time. Returns a stop function.
Chargingcharge(durationMs = 1500)Charging a shot or spell
release()Releasing a charged shot
Movementjump()Jumping, lift-off
land()Landing
UI and feedbackmenuTick()Menu navigation, focus
error()Invalid action
notification()A gentle, non-urgent ping
Game flowgameOver()Death, run ended
countdown()"3, 2, 1" round start
TensionbossApproach()A boss appears, tension rises

RECIPE_META (also exported) describes each recipe's title, use case, duration and intensity envelope, which is handy if you want to show a pattern preview in your own UI.

#Behaviour you get for free

#Best practices

Match the pattern to the moment.

Game momentPattern
Every frame or pointer moveDon't. Change haptics on state transitions instead.
A tap or presstap or pulse
A decisive momentimpact, success, fail
A state that lastsheartbeat, idle, rampUp
A cinematic momentwave, or a chain of ramps

Don't send motor commands every frame.

// Avoid: 60 calls a second that never settle
game.onTick(() => {
  void haptics.pulse(0.5, 16);
});

// Better: start and stop continuous patterns when state changes
let stopBeat: (() => void) | null = null;
state.subscribe('health', (hp) => {
  if (hp < 30 && !stopBeat) stopBeat = haptics.heartbeat(160 - hp, 0.6);
  if (hp >= 30 && stopBeat) { stopBeat(); stopBeat = null; }
});

Fire and forget when game logic shouldn't wait.

function onHit() {
  void haptics.streak(5);   // runs alongside your game logic
  state.current.score += 1;
}

Layer ambient and discrete feedback.

const stopBeat = haptics.heartbeat(120, 0.5);
onEnemyHit(() => haptics.impact(0.8));
onPlayerSafe(() => stopBeat());

Always clean up continuous patterns.

let stopHeartbeat: (() => void) | null = null;

function onGameOver() {
  haptics.stop();
  stopHeartbeat?.();
  stopHeartbeat = null;
  void haptics.fail();
}

Charge and release.

let charging = false;
game.onPointer(async (e) => {
  if (e.type === 'down') {
    charging = true;
    await haptics.rampUp({ from: 0, to: 1, durationMs: 1500 });
    if (charging) await haptics.impact(1);
  }
  if (e.type === 'up') {
    charging = false;
    haptics.stop();
  }
});

#Going lower-level

If you need exact control, use the device directly. Values are 0-200 per motor, and timed sequences run inside Fluffer as a single call:

const device = game.device;
if (device) {
  await device.setAllMotors({ m1: 200, m2: 0, m3: 200, m4: 0 });
  await device.executeRoutine([
    { motors: { m1: 60,  m2: 60,  m3: 60,  m4: 60  }, durationMs: 150 },
    { motors: { m1: 160, m2: 160, m3: 160, m4: 160 }, durationMs: 150 },
  ]);
  await device.stopVibration();
}

See Bridge protocol for every device method.

#Testing your haptics

In the browser, every haptic call goes to the mock launcher. To see the motor levels, use the mock monitor described in Getting started. To assert on calls in a test:

import { installMockLauncher } from '@fluffer/launcher-sdk';
import { createGame, haptics } from '@fluffer/game-kit';

const mock = installMockLauncher();
await createGame({ autoMock: false });

await haptics.impact(0.9);

const peak = mock.getCalls()
  .filter((c) => c.method === 'device.setAllMotors')
  .map((c) => (c.params as { m1: number }).m1)
  .reduce((a, b) => Math.max(a, b), 0);

console.assert(peak === 180);   // 0.9 × 200
mock.uninstall();