Web Launcher SDK
Getting started
Scaffold a Fluffer app, run it in the browser against a mock toy, test haptics without hardware, and package it for upload.
This guide takes you from nothing to a Fluffer app running in your browser, then to an upload-ready package. It takes about five minutes.
#What you need
- Node.js 20 or newer and git.
- Access to the Fluffer SDK repository (see step 1).
- An editor.
- Optional: a Fluffer, MotorBunny or PlayMate toy. You can build almost all of your app without one.
#1. Get the SDK and scaffold an app
The SDK packages (@fluffer/game-kit, @fluffer/launcher-sdk) aren't on the npm registry yet. During the developer preview we share the SDK repository with each developer: email developers@fluffer.app with a line about what you’re building and we’ll send you access. Apps are created inside that repository; a script copies a starter template and wires everything up for you.
# in the SDK repository we share with you
npm install
npm run build # builds the SDK packages your app imports
npm run create-game -- --slug=my-game
npm install # links the new app into the workspace
cd games/my-game
npm run dev
Open the URL Vite prints. Your app is running against a mock launcher with a virtual toy.
#Options for create-game
| Option | Values | Default |
|---|---|---|
--slug | Lowercase letters, digits and dashes, e.g. my-game. Required. | none |
--template | html5-vanilla (2D canvas) or threejs (3D / WebGL) | html5-vanilla |
--name | Display name, e.g. "Bunny Dash" | derived from the slug |
--capabilities | Comma-separated capability tokens, e.g. device.vibrate,device.rotate | the template's |
--orientation | landscape, portrait or auto | the template's |
npm run create-game -- --slug=bunny-dash --template=threejs --capabilities=device.vibrate,device.rotate --orientation=landscape
The script gives the app its own dev-server port (5174 or higher), sets up manifest.json from your options and creates a public/ folder for assets.
#2. What's in your app
games/my-game/
src/main.ts # your code; imports from @fluffer/game-kit
public/ # images, audio, fonts; copied as-is into dist/
index.html # a full-screen <canvas> that loads src/main.ts
manifest.json # what your app needs from Fluffer
package.json # dev / build / package scripts
vite.config.ts # base './' and target es2022: keep both
Two settings in vite.config.ts matter. base: './' makes asset URLs relative so the bundle works wherever it's hosted. target: 'es2022' allows top-level await, which await createGame() relies on.
#3. Write your first game loop
Replace src/main.ts with this:
import { createGame, haptics } from '@fluffer/game-kit';
const game = await createGame({
capabilities: ['device.vibrate'],
canvas: document.querySelector('canvas'),
});
const state = game.state(
{ score: 0, highScore: 0 },
{ persist: ['highScore'] }, // survives reloads
);
game.onTick((dt) => {
// update + render; dt is milliseconds since the last frame.
// Paused automatically when Fluffer goes to the background.
});
game.onPointer((e) => {
if (e.type !== 'down') return;
state.current.score += 1;
void haptics.tap(); // small acknowledgement
if (state.current.score % 10 === 0) void haptics.success();
if (state.current.score > state.current.highScore) {
state.current.highScore = state.current.score; // saved automatically
}
});
game.onKey((e) => {
if (e.type === 'keydown' && e.key === 'Escape') void game.exit();
});
Save and Vite hot-reloads.
#What createGame() gives you
| Member | What it does |
|---|---|
game.status | 'initialising', 'ready', 'paused', 'fuel-empty', 'exited' or 'error' |
game.device | The connected toy (LauncherDevice), or null when there isn't one |
game.capabilities | The capabilities Fluffer actually granted. Asking isn't a guarantee. |
game.onTick((dt, total) => ...) | Frame loop driven by requestAnimationFrame; auto-paused |
game.onPointer((e) => ...) | Mouse, touch and pen in one stream. e.type is 'down', 'move' or 'up'; e.nx and e.ny run from -1 to 1 with 0 at the centre; e.pressed is true while held. |
game.onKey((e) => ...) | Keyboard events (keydown and keyup); auto-paused |
game.state(initial, options) | Reactive state with optional persistence |
game.storage | On-device documents and files (Storage) |
game.tips, game.media, game.auth | Tips, saving to the photo library, accounts (Bridge, Accounts) |
game.exit() | Asks Fluffer to close your app |
Every on* method returns an unsubscribe function.
#createGame() options
| Option | Type | Default | Notes |
|---|---|---|---|
capabilities | Capability[] | ['device.vibrate'] | Requested at handshake. Must also be declared in your manifest. |
canvas | HTMLCanvasElement | none | Pointer coordinates are normalised to this element |
slug | string | 'mock-game' | Your app's slug. Names your storage database and the audience of mock account tokens. |
autoMock | boolean | true | Install the mock launcher when not running inside Fluffer |
maxMotorCallsPerSecond | number | 50 | Excess motor calls are coalesced: the most recent value wins |
pauseOnEmptyFuel | boolean | false | Auto-pause when tip fuel runs out (needs tips.fuel) |
resumeFuelThreshold | number | 1 | Fuel level that ends a fuel-empty pause |
sdkVersion | string | SDK version | Override the version sent in the handshake |
#4. Test without a toy
#In the browser (always on)
When your app isn't running inside Fluffer, createGame() installs a mock launcher automatically. The mock answers the handshake with a virtual MBBunny (four motors and rotation), grants the capabilities you asked for, and accepts every call. You can build and tune most of your app this way.
#See the motors with the mock monitor
The SDK repository includes a visualiser at examples/mock-monitor. It shows live motor levels, rotation, an intensity chart and a log of every bridge call. It also has buttons to simulate the toy connecting and disconnecting, and a tips simulator.
Start it in a second terminal:
cd examples/mock-monitor && npm run dev # runs on port 5181
Add this to the top of your src/main.ts. It only runs in development and is left out of production builds:
if (import.meta.env?.DEV) {
const { autoEnable } = await import('../../../examples/mock-monitor/dev-tap.js');
autoEnable();
}
Then open your app with ?monitor=1 (for example http://localhost:5174/?monitor=1). The monitor opens in a popup and mirrors every call. If the browser blocks the popup, click the banner the app shows.
#Drive the mock yourself
For tests, or to simulate events, install the mock explicitly from @fluffer/launcher-sdk and turn off the automatic one:
import { installMockLauncher } from '@fluffer/launcher-sdk';
import { createGame, haptics } from '@fluffer/game-kit';
const mock = installMockLauncher({
device: { id: 'test-1', family: 'Fluffer', type: 'MBBunny', state: 'Connected', motorCount: 4, hasRotation: true },
capabilities: ['device.vibrate', 'device.rotate'],
});
const game = await createGame({ autoMock: false, capabilities: ['device.vibrate'] });
await haptics.impact(0.9);
console.log(mock.lastCall('device.setAllMotors')?.params); // { m1: 180, m2: 180, m3: 180, m4: 180 }
mock.uninstall();
| Mock option | What it sets |
|---|---|
device | The pretend toy (DeviceSummary), or null for "no toy connected" |
user | The pretend user returned by user.get() |
capabilities | What the mock is willing to grant (default: all) |
slug | Audience for mock account tokens |
auth | Pretend account for account.identity (see Accounts) |
handlers | Per-method overrides, e.g. make a call fail |
onCall | Callback for every bridge call |
The handle returned by installMockLauncher() has emit(topic, data), getCalls(), lastCall(method), setHandler(method, handler), getGrantedCapabilities(), auth and uninstall().
#Inside Fluffer without a toy
If the player hasn't connected a toy, game.device is null and haptic calls do nothing, without errors. Make sure your app is still playable in that state. If a toy connects after your app has started, game-kit picks it up automatically.
#5. Build and package
npm run package
# ✓ packaged my-game → my-game.zip (0.03 MB)
npm run package builds your app, checks manifest.json with the SDK's validator, and zips dist/ plus manifest.json into <slug>.zip. That zip is what you hand to Fluffer. See Publishing for the requirements and how to submit.
Before you send it, serve the built bundle and play it end to end:
npx serve dist
#Troubleshooting
| Problem | Likely cause | Fix |
|---|---|---|
HOST_UNAVAILABLE from createGame() | Running in a plain browser with autoMock: false | Leave autoMock on, or call installMockLauncher() first |
PERMISSION_DENIED on a rotation call | device.rotate isn't declared or requested | Add it to manifest.json and to createGame({ capabilities }) |
| Audio doesn't play | Browser autoplay rules | Create or resume your AudioContext in the first pointerdown |
| Haptics keep running after game over | A continuous pattern's stop function wasn't called | Call haptics.stop() and any stop functions you hold |
| Game keeps ticking in the background | Using setInterval instead of game.onTick | Move per-frame work into game.onTick |
TypeScript can't find @fluffer/game-kit | SDK packages not built, or app outside games/ | Run npm run build at the repository root and keep the app under games/<slug>/ |