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
| Call | Returns | Use 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
| Call | Returns | Use 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
| Call | Returns | Use it for |
|---|---|---|
heartbeat(bpm, strength = 0.6) | stop function | Rhythmic pulses at a tempo. Tension, low health, suspense. |
idle(strength = 0.15) | stop function | A 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
| Call | Returns | Use 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
| Call | Returns | Use it for |
|---|---|---|
stop() | void | Stops 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();
| Category | Recipe | Use it for |
|---|---|---|
| Combat and impact | collision() | 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 rewards | pickup() | Coins, items, power-ups |
heal() | Healing, regeneration | |
buff() | Activating a power-up | |
levelUp() | Level-up, stage clear | |
victory() | Victory screen | |
| Health and status | lowHealth(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. | |
| Charging | charge(durationMs = 1500) | Charging a shot or spell |
release() | Releasing a charged shot | |
| Movement | jump() | Jumping, lift-off |
land() | Landing | |
| UI and feedback | menuTick() | Menu navigation, focus |
error() | Invalid action | |
notification() | A gentle, non-urgent ping | |
| Game flow | gameOver() | Death, run ended |
countdown() | "3, 2, 1" round start | |
| Tension | bossApproach() | 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
- Pause-aware. When Fluffer pauses your app, running patterns stop and the motors go to zero. Fluffer enforces this anyway; the kit also cancels in-flight ramps so they don't resume unexpectedly.
- Cancellable.
haptics.stop()cancels everything, including fire-and-forget calls likevoid haptics.rampUp(...). A cancelled pattern sends no further motor commands. - Rate-limited. Motor commands are capped at 50 per second by default (change it with
createGame({ maxMotorCallsPerSecond })). Extra calls are coalesced and the most recent value wins. - One queue. If a heartbeat and an impact land at the same moment, the most recent value wins. The player feels "ambient plus spike", not a muddled sum.
- No toy, no problem. With no toy connected, haptic calls do nothing and don't throw.
#Best practices
Match the pattern to the moment.
| Game moment | Pattern |
|---|---|
| Every frame or pointer move | Don't. Change haptics on state transitions instead. |
| A tap or press | tap or pulse |
| A decisive moment | impact, success, fail |
| A state that lasts | heartbeat, idle, rampUp |
| A cinematic moment | wave, 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();