Web Launcher SDK
Storage
Keep settings, documents and files on the device with game.state and game.storage: private per app, with limits and error codes.
Your app can keep its own data on the player's device: settings, saves, libraries, recordings. @fluffer/game-kit gives you two tools:
| Tool | Best for | Size |
|---|---|---|
game.state(initial, { persist }) | A handful of settings and small values (high score, volume, last level) | Small |
game.storage | Anything that grows: lists of documents, saved games, photos, audio, video | Up to 25 MB of documents and 500 MB of files |
Both are private to your app and need no capability or consent, because the data never leaves the device.
#Your data is private to your app
Fluffer serves every app from its own address (https://<your-app>.fluffer.app/). Browsers and web views keep storage separate per address, so no other app can open your app's data, and you can't open theirs.
Storage is currently device-only: it isn't synced between devices or backed up to the player's account. If the player reinstalls Fluffer or switches phones, plan for the data to be gone.
#Persistent state with game.state
game.state() creates a reactive state object. Keys listed in persist are written to localStorage on every change and loaded again on the next launch.
const state = game.state(
{ score: 0, lives: 3, highScore: 0, volume: 0.8 },
{ persist: ['highScore', 'volume'] },
);
state.current.score += 1; // mutate directly
state.subscribe('highScore', (next, prev) => showBanner(next));
state.replace({ score: 0, lives: 3 }); // update several keys
state.reset(); // back to the initial values; persisted keys reload
| Option | Default | Description |
|---|---|---|
persist | none | Keys to save. Everything else is kept in memory only. |
storageKey | 'fluffer:game:' | Prefix for the localStorage keys |
Persisted values must be JSON. Use game.state for a few small values; localStorage is small and shared by everything in your app.
#Documents and files with game.storage
game.storage stores JSON documents in named collections, and blobs (binary files) under keys. It's backed by IndexedDB in the web view.
Pass your app's slug to createGame() so your database has a stable name:
const game = await createGame({ slug: 'my-game', capabilities: ['device.vibrate'] });
#Documents
interface SaveSlot { level: number; coins: number; name: string }
const saves = game.storage.collection<SaveSlot>('saves');
const doc = await saves.put('slot-1', { level: 3, coins: 120, name: 'Morning run' });
// doc: { collection: 'saves', id: 'slot-1', data: {...}, version: 1, updatedAt: '2026-...' }
const slot = await saves.get('slot-1'); // StoredDoc<SaveSlot> or null
const all = await saves.list(); // newest first
await saves.delete('slot-1'); // resolves whether or not it existed
const unsubscribe = saves.subscribe((change) => {
// { collection, id, version, deleted, origin: 'local' }
refreshSaveList();
});
| Method | Returns | Notes |
|---|---|---|
collection<T>(name) | StorageCollection<T> | |
get(id) | StoredDoc<T> or null | |
put(id, data, { ifVersion }?) | StoredDoc<T> | Creates or replaces. data must be JSON, at most 256 KB. |
delete(id, { ifVersion }?) | void | |
list() | StoredDoc<T>[] | Most recently written first |
subscribe(listener) | unsubscribe function | Called after each write to this collection |
interface StoredDoc<T> {
collection: string;
id: string;
data: T;
version: number; // goes up by one on every write
updatedAt: string; // ISO timestamp of the last write
}
#Safe updates with ifVersion
Pass the version you last read to make sure nobody else changed the document in between. If it changed, the write fails with CONFLICT and nothing is overwritten. Use ifVersion: 0 to mean "only create, never overwrite".
import { FlufferError } from '@fluffer/game-kit';
const current = await saves.get('slot-1');
try {
await saves.put('slot-1', { ...current!.data, coins: current!.data.coins + 10 }, { ifVersion: current!.version });
} catch (err) {
if (err instanceof FlufferError && (err.code as string) === 'CONFLICT') {
// reload and try again
}
}
await saves.put('slot-2', { level: 1, coins: 0, name: 'New' }, { ifVersion: 0 }); // fails if slot-2 exists
#Blobs
const photo = await new Promise<Blob>((r) => canvas.toBlob((b) => r(b!), 'image/jpeg', 0.9));
const info = await game.storage.blobs.put('photos/run-42.jpg', photo);
// info: { key: 'photos/run-42.jpg', mime: 'image/jpeg', bytes: 183422, updatedAt: '...' }
const back = await game.storage.blobs.get('photos/run-42.jpg'); // Blob or null
const photos = await game.storage.blobs.list('photos/'); // metadata only, no bytes
await game.storage.blobs.delete('photos/run-42.jpg');
| Method | Returns | Notes |
|---|---|---|
blobs.put(key, blob, { mime }?) | StoredBlob | At most 50 MB each. mime defaults to the blob's type. |
blobs.get(key) | Blob or null | |
blobs.delete(key) | void | |
blobs.list(prefix?) | StoredBlob[] | Sorted by key; doesn't load the bytes |
#Usage and persistence
const { docsBytes, blobBytes, docsQuota, blobQuota, synced } = await game.storage.usage();
// synced is false: data lives on this device only
if (!game.storage.isPersistent()) {
// No IndexedDB here: the API still works, but nothing survives a reload.
}
#Names and limits
| Item | Rule |
|---|---|
| Collection name | Starts with a lowercase letter; lowercase letters, digits, _ and -; up to 32 characters |
| Document id | Letters, digits, _ and -; 1 to 64 characters |
| Blob key | Letters, digits, _, ., / and -; 1 to 128 characters (use / for folders) |
| One document | 256 KB of JSON |
| One blob | 50 MB |
| All documents | 25 MB per app |
| All blobs | 500 MB per app |
These are also exported as STORAGE_LIMITS.
#Errors
Storage calls reject with a FlufferError whose code is one of:
| Code | When |
|---|---|
INVALID_ARGUMENT | Bad collection name, document id or blob key, or data that isn't JSON |
TOO_LARGE | A document over 256 KB or a blob over 50 MB |
QUOTA_EXCEEDED | Your app's quota is full, or the device is out of space |
CONFLICT | ifVersion didn't match. The current document is attached to the error as cause. |
#Testing
createStorage() and createMemoryBackend() let you build an in-memory store for unit tests:
import { createStorage, createMemoryBackend } from '@fluffer/game-kit';
const storage = createStorage({ backend: createMemoryBackend() });
await storage.collection('saves').put('a', { level: 1 });
#Saving to the photo library
game.storage keeps files inside your app. To save a photo or video to the player's camera roll, where they can see and share it, use game.media with the media.photos capability. See Bridge protocol.