Fluffer by MotorbunnyDevelopers

Web Launcher SDK

Bridge protocol

The fluffer-bridge/1 protocol: handshake, envelopes, errors, capabilities, and every device, tips, media and user method and event.

The bridge is the channel between your app and Fluffer. It's a small JSON request/response protocol called fluffer-bridge/1. Most apps never see it directly, because @fluffer/game-kit and @fluffer/launcher-sdk wrap every method in typed functions. This page documents the protocol and shows the SDK call for each method.

#How it works

Before your app's first frame, Fluffer injects a bootstrap that defines window.fluffer. The SDK finds it and talks through it. In a plain browser there's no window.fluffer, so the SDK installs a mock launcher that behaves the same way.

Every message is a JSON envelope:

type Envelope =
  | { v: 1; t: 'req'; id: string; method: string; params?: unknown }   // app → Fluffer
  | { v: 1; t: 'res'; id: string; result?: unknown; error?: BridgeError } // Fluffer → app
  | { v: 1; t: 'evt'; topic: string; data?: unknown };                  // Fluffer → app

interface BridgeError {
  code: string;      // e.g. 'PERMISSION_DENIED'
  message: string;
  data?: unknown;
}

Requests may also carry a per-session nonce, n, which Fluffer's bootstrap adds. Your app never sets it.

#Handshake and versioning

Your app's first call must be host.handshake. It sends the SDK version and the capabilities it wants. Fluffer replies with what it granted, the user summary and the connected toy. The SDK queues every other call until the handshake finishes.

{"v":1,"t":"req","id":"a1","method":"host.handshake",
 "params":{"sdkVersion":"0.1.0","requestedCapabilities":["device.vibrate","device.rotate"]}}

{"v":1,"t":"res","id":"a1",
 "result":{"hostVersion":"1.0.0",
           "grantedCapabilities":["device.vibrate","device.rotate"],
           "user":null,
           "device":{"id":"MBB-7c:9a","family":"Fluffer","type":"MBBunny","state":"Connected","motorCount":4,"hasRotation":true}}}

grantedCapabilities is the overlap of what you requested, what your manifest declares, and what the player allows. Always check it rather than assuming.

With the SDK, createGame() or new Scanner(...).init() performs the handshake for you:

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

const game = await createGame({ capabilities: ['device.vibrate', 'device.rotate'] });
if (!game.capabilities.includes('device.rotate')) hideSpinControls();

Versioning rules. Minor versions add fields and methods but never remove or rename them, so ignore fields you don't recognise. A new major version (v: 2) would be introduced alongside v1, not instead of it. Deprecated fields stay for two minor versions.

#Errors

A failed request resolves with an error instead of a result. The SDK turns it into a thrown FlufferError with a code:

import { FlufferError, FlufferErrorCode } from '@fluffer/game-kit';

try {
  await game.device?.setRotationSpeed(120);
} catch (err) {
  if (err instanceof FlufferError && err.code === FlufferErrorCode.PermissionDenied) {
    // device.rotate wasn't granted
  }
}
CodeMeaning
NOT_CONNECTEDNo toy is connected
BLE_UNAVAILABLEBluetooth is off or unavailable on the phone
PERMISSION_DENIEDCapability not declared, not granted, or refused by the player
COMMAND_FAILEDThe toy rejected the command, or the toy doesn't support it
INVALID_ARGUMENTA parameter failed validation
TIMEOUTFluffer didn't respond in time
METHOD_NOT_FOUNDUnknown method, or an older Fluffer version without it
VERSION_MISMATCHProtocol version not supported
RATE_LIMITEDToo many calls to this method
HOST_UNAVAILABLENo launcher found (plain browser with the mock turned off)
QUOTA_EXCEEDED, TOO_LARGE, CONFLICTStorage errors, see Storage

FlufferErrorCode (exported by both packages) covers the device and permission codes; @fluffer/launcher-sdk also exports BridgeErrorCode with every code in the table (for example BridgeErrorCode.PermissionDenied === 'PERMISSION_DENIED').

#Capabilities

Capabilities are coarse permissions. Declare them in your manifest and request them at handshake. Anything outside the granted set is refused with PERMISSION_DENIED.

CapabilityGrants
device.vibratedevice.setAllMotors, device.setMotorIntensity, device.stopVibration, device.executeRoutine, device.shake
device.rotatedevice.setRotationSpeed, device.stopRotation
user.profileuser.get (asks the player for consent first)
tips.fueltips.getState, tips.setConfig and the tips.received / tips.fuelChanged events
media.photosmedia.savePhoto, media.beginSave, media.appendChunk, media.commitSave, media.abortSave
account.identityauth.getSession, auth.requestConsent, auth.getToken, auth.revoke and the auth.changed event

host.handshake, host.exit, host.setOrientation and device.getCurrent are always available.

#Host methods

MethodParamsResult
host.handshake{ sdkVersion, requestedCapabilities }{ hostVersion, grantedCapabilities, user, device }
host.exitnonenull
host.setOrientation{ orientation: 'portrait' } or { orientation: 'landscape' }null
import { host } from '@fluffer/game-kit';

await host.setOrientation('portrait');   // Fluffer may ignore this if your manifest fixes orientation
console.log(host.device, host.capabilities, host.ready);
await host.exit();                       // or game.exit(), which also stops haptics first

#Device control

The toy is the one the player paired in Fluffer. Motor intensities on the wire are integers from 0 to 200. game-kit's haptics use 0 to 1 and convert for you; see Haptics.

MethodParamsResultCapability
device.getCurrentnoneDeviceSummary or nullalways
device.setAllMotors{ m1, m2, m3, m4 }, each 0-200nulldevice.vibrate
device.setMotorIntensity{ motor: 0-3, intensity: 0-200 }nulldevice.vibrate
device.stopVibrationnonenulldevice.vibrate
device.executeRoutine{ steps: PatternStep[] }nulldevice.vibrate
device.shake{ count: 1-10 }nulldevice.vibrate
device.setRotationSpeed{ speed: 0-200 }nulldevice.rotate
device.stopRotationnonenulldevice.rotate
interface DeviceSummary {
  id: string;
  family: 'Fluffer' | 'MotorBunny' | 'PlayMate';
  type: string;                 // e.g. 'MBBunny', 'PM1', 'MB2'
  state: 'Disconnected' | 'Connecting' | 'Connected' | 'Disconnecting';
  motorCount?: number;
  hasRotation?: boolean;
}

interface PatternStep {
  motors: { m1: number; m2: number; m3: number; m4: number };   // 0-200 each
  durationMs: number;
}

With the SDK, these are methods on the device object (game.device in game-kit, or scanner.createDevice() in launcher-sdk):

const device = game.device;
if (device) {
  await device.setAllMotors({ m1: 150, m2: 150, m3: 0, m4: 0 });
  await device.setMotorIntensity(2, 120);
  await device.stopVibration();

  // A timed routine runs inside Fluffer as one call: smooth and low-latency.
  await device.executeRoutine([
    { motors: { m1: 80,  m2: 80,  m3: 80,  m4: 80  }, durationMs: 200 },
    { motors: { m1: 200, m2: 200, m3: 200, m4: 200 }, durationMs: 150 },
    { motors: { m1: 0,   m2: 0,   m3: 0,   m4: 0   }, durationMs: 0 },
  ]);

  await device.shake(3);

  if (device.hasRotation) {
    await device.setRotationSpeed(120);
    await device.stopRotation();
  }

}

LauncherDevice also exposes id, name, state, motorCount, hasRotation and getDeviceDetails(). Rotation is only on toys with a rotation motor (for example MBBunny); setRotationSpeed throws COMMAND_FAILED on toys without one.

#Events

Fluffer pushes events to your app. With game-kit, use the dedicated helpers (game.tips.onTip, game.auth.onChange).

TopicDataWhen
device.connectionChanged{ state, deviceId }The toy connects or disconnects
tips.receivedTipReceivedEventA tip added fuel (needs tips.fuel and the player's Allow)
tips.fuelChangedFuelChangedEventThe fuel level moved (needs tips.fuel and the player's Allow)
tips.accessChanged{ permission }The player allowed or took back your app's access to tips
auth.changedAuthSessionSign-in, sign-out, consent or scope changes (needs account.identity)

#Lifecycle

  1. Inject. Fluffer adds the bridge before your page runs.
  2. Handshake. Your first call; everything else waits for it.
  3. Operate. Requests, responses and events.
  4. Exit. Your app calls host.exit, or the player navigates back and Fluffer closes the web view. Save progress as you go (see Storage) rather than at exit.

Leaving and backgrounding. When the player leaves your app, Fluffer closes the web view and the toy stops. Save progress as you go rather than relying on a last-moment callback.

If the toy connects after your app has started (Fluffer was still reconnecting), game-kit picks it up automatically. With launcher-sdk, call scanner.refreshCurrentDevice() or listen with scanner.addDeviceListChanged().

#User profile (user.profile)

MethodParamsResult
user.getnone{ id, displayName?, isGuest } or null

user.get asks the player first. The first call shows Fluffer's consent sheet; if the player declines, you get { isGuest: true } with no name. Call it from a tap on a screen that explains why, never at launch or from a timer. The id is specific to your app, not a global user id.

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

showNameButton.onclick = async () => {
  const me = await user.get();
  greet(me && !me.isGuest ? me.displayName : 'Guest');
};

For verifiable identity your backend can trust, use account.identity; see Accounts.

#Tips and fuel (tips.fuel)

When the player is running a live show, tips from viewers can be turned into fuel. Fluffer keeps one fuel tank (Tip4Fuel), fills it when tips arrive, and drains it while the motors run. Tips come from Chaturbate and from the player's own Remote API key (POST /tip, POST /fuel); you get both, each marked with its platform. Your app can show the tank, react to tips, and pause when the tank is empty.

The player has to allow it. Tips carry other people's names and messages, so each app needs the player's permission, the same way account details do. The first time your app calls tips.requestAccess, Fluffer shows "Let your app see your tips?", explaining that you'll see who tipped (unless anonymous), how many tokens, their message and the fuel level. The answer is remembered for your app; the player can change it in Settings → Apps with access. Until they allow it you get no tip or fuel events, and tips.getState fails with PERMISSION_DENIED (data.permission is prompt if you haven't asked yet). Only tips.requestAccess asks, so call it from a tap on a screen that explains why, never at launch.

MethodParamsResult
tips.requestAccessnone`{ permission: 'granted' \'denied' \'prompt' }`
tips.getStatenoneTipsState (never asks; PERMISSION_DENIED until allowed)
tips.setConfigTipsConfignull
interface TipReceivedEvent {
  id: string;
  platform: 'chaturbate' | 'api';
  tipper: string | null;     // null when anonymous
  isAnon: boolean;
  tokens: number;            // tokens the tipper sent
  message?: string;
  fuelAdded: number;         // fuel Fluffer added for this tip
  fuelLevel: number;         // level after the tip
  tankSize: number;          // e.g. 100
  timestamp: number;
}

interface FuelChangedEvent {
  amount: number;            // how much it moved (always positive)
  level: number;
  source: 'tip' | 'api' | 'consumption' | 'reset';  // 'api' = POST /fuel
  timestamp: number;
  delta: number;             // + on tip or fuel, − on consumption
  tankSize: number;
  isExhausted: boolean;      // level <= 0
  reason: 'tip' | 'consumption' | 'reset';  // older field; any increase is 'tip'
}

interface TipsState {
  fuelLevel: number;
  tankSize: number;
  isExhausted: boolean;
  lastTip: TipReceivedEvent | null;
}

interface TipsConfig {
  fuelEfficiency?: number;     // 1-10 multiplier on how fast running motors burn fuel
  fuelLeakPerSecond?: number;  // constant idle drain; 0 = none
}

With game-kit:

const game = await createGame({
  capabilities: ['device.vibrate', 'tips.fuel'],
  pauseOnEmptyFuel: true,          // game.status becomes 'fuel-empty' at zero
});

game.tips.onTip((t) => showToast(`${t.isAnon ? 'Someone' : t.tipper} added ${t.fuelAdded} fuel`));
game.tips.onFuelChange((f) => drawGauge(f.level / f.tankSize));
await game.tips.setConfig({ fuelEfficiency: 3, fuelLeakPerSecond: 0 });

const { fuelLevel, tankSize } = game.tips.state();
game.setPauseOnEmptyFuel(false);   // e.g. a "free play" toggle

game.tips is always present. If tips.fuel wasn't granted, or the player hasn't allowed your app to see tips, its methods quietly do nothing. With launcher-sdk, call tips.requestAccess and listen for tips.accessChanged to know when tips start or stop.

#Saving photos and video (media.photos)

Save images and recordings your app creates to the player's photo library. Fluffer asks the OS for photo access on the first save.

MethodParamsResult
media.savePhoto{ base64, mimeType: 'image/jpeg' or 'image/png', filename? }{ assetId }
media.beginSave{ kind: 'photo' or 'video', mimeType, filename?, totalBytes? }{ sessionId, maxChunkChars }
media.appendChunk{ sessionId, seq, base64 }{ receivedBytes }
media.commitSave{ sessionId }{ assetId }
media.abortSave{ sessionId }null

A photo fits in one media.savePhoto call. Larger files (video) stream through a session: beginSave, then appendChunk with seq 0, 1, 2 and so on, then commitSave (or abortSave to discard). base64 is raw base64 without a data: prefix. Limits: at most 1,400,000 base64 characters per chunk, 64 MB per session, 2 sessions at once, and sessions idle for more than 120 seconds are discarded. A skipped or repeated seq fails with INVALID_ARGUMENT and leaves the session open so you can retry that chunk. assetId may be null on platforms that don't expose one.

game-kit handles encoding and chunking for you:

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

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

saveButton.hidden = !game.media.isAvailable();

saveButton.onclick = async () => {
  try {
    const blob = await new Promise<Blob>((r) => canvas.toBlob((b) => r(b!), 'image/jpeg', 0.9));
    await game.media.savePhoto(blob, { filename: 'my-score.jpg' });   // a Blob or a data: URL
    await game.media.saveVideo(recording, {
      onProgress: (sent, total) => showProgress(sent / total),
    });
  } catch (err) {
    const reason = err instanceof FlufferError ? (err.cause as { reason?: string } | undefined)?.reason : undefined;
    if (reason === 'os-permission') {
      showMessage('Allow photo access for Fluffer in Settings to save.');
    }
  }
};

Unlike the tips helpers, save calls reject with PERMISSION_DENIED when media.photos wasn't granted, so a failed save never looks like a successful one. Check isAvailable() and hide save buttons instead. When the player has refused photo access at the OS level, the error carries reason: 'os-permission': in the wire envelope it's in error.data, and on the SDK's FlufferError it's in err.cause. Use it to show "enable photo access in Settings" instead of a generic failure.

#Accounts (account.identity)

auth.getSession, auth.requestConsent, auth.getToken, auth.revoke and the auth.changed event give your app consent-first, verifiable identity. They have their own page: Accounts.

#Using the bridge without the SDK

We recommend the SDK. If you can't use it, you can call the injected bridge directly. Requests return promises that resolve with result or reject with { code, message, data }:

if (window.fluffer && typeof window.fluffer.call === 'function') {
  const hs = await window.fluffer.call('host.handshake', {
    sdkVersion: '0.1.0',
    requestedCapabilities: ['device.vibrate'],
  });
  if (hs.device) await window.fluffer.call('device.setAllMotors', { m1: 120, m2: 120, m3: 0, m4: 0 });
}

@fluffer/launcher-sdk also exports isInLauncher() and isFlufferBridgeReady() if you need to detect the environment yourself.