Saves & Leaderboards
Cloud saves that follow you across devices, and shared scoreboards
Brewser apps can remember your data and let you compete. Two separate systems power this:
- Saves — your own private data for an app. Only you can read yours.
- Leaderboards — a public, shared scoreboard an app submits scores to.
They share nothing. A save is a private box only you can open; a leaderboard is a public ranking every player can see.
Saves that follow you
When an app saves, the write happens instantly on the device you're using, then quietly uploads to your Brewser account a moment later. Nothing waits on the network, and saving works offline — the upload just happens when you're back online.
Because the copy lives on your account, your progress follows you across devices. Save on the web, pick it up on the Switch; save on the Switch, pick it up in your browser.
- Signed in — saves sync to your account and appear on your other devices.
- Signed out — saves still work, instantly, but stay only on that device. They sync up once you sign in.
Your saves are private. There is no browsing other people's saves, and an app only ever sees its own save box — not other apps'.
Leaderboards
Some apps post scores to a shared leaderboard so you can see how you stack up. Leaderboards are opt-in by the app and public: anyone can view the rankings, even signed out. To submit a score you need to be signed in, so every entry belongs to a real player.
A leaderboard keeps your best result — submitting a worse score never pushes you down. Depending on the game, "best" means the highest score (points) or the lowest (fastest lap time). You can view the top players, the scores immediately around your own rank, or remove your entry entirely.
For developers
Saves and leaderboards are provided by brewser.js, a tiny drop-in SDK. It
gives you a two-tier save (instant local + background account sync), a small
records database layered on top, and a public leaderboard — all under one global
brewser object.
Getting started
Ship brewser.js alongside your app and include it before your own script:
<script src="./brewser.js"></script>
<script>
// window.brewser is now available
brewser.save({ level: 3, coins: 120 });
const data = brewser.load(); // { level: 3, coins: 120 }
</script>The SDK auto-detects your package id from the catalogue URL path
(.../apps/{group}/{id}/...). If your app isn't served from that path (local
dev, an unusual mount), set it explicitly:
brewser.configure({ packageId: 'com.you.mygame' });No manifest permission required. Saves and leaderboards are not hardware, so they need nothing declared in your manifest. They only depend on the user being signed in for the cloud parts — see Identity & sign-in.
The two-tier model
Every write goes to localStorage first (instant, synchronous, offline-safe),
then a debounced background fetch pushes it to the account. Reads come from
localStorage, which always wins — the user may have saved offline, or a
device clock may be wrong, so the local copy is authoritative for that device.
To bring in a copy made elsewhere, you explicitly pull().
This means: save()/load() never block and never fail on a bad network;
cross-device only happens when you ask for it.
Whole-save API
The simplest model — one JSON blob per app, per user.
| Call | Returns | Notes |
|---|---|---|
brewser.save(data) | boolean | data = any JSON value. Writes local instantly, schedules a background push. |
brewser.load() | your data, or null | Reads the local copy. Instant. |
brewser.info() | { updatedAt } or null | Timestamp (epoch ms) of the local save. |
brewser.pull(opts) | Promise<{ ok, data, updatedAt, reason? }> | Fetches the account copy. Pass { adopt: true } to also write it locally. |
brewser.sync() | Promise | Force an immediate push (skips the debounce) — for a "Sync now" button. |
brewser.clearLocal() | boolean | Clears the local save. Does not touch the account copy. |
brewser.canSync() | boolean | Whether a cross-device sync is possible right now (i.e. signed in). |
pull() deliberately does not overwrite local by default — it hands you the
server copy so you decide (e.g. show a "cloud save is newer, load it?" prompt).
Use { adopt: true } when local is empty or the user chose "load from cloud":
// On boot: if nothing local, adopt the account copy.
if (brewser.load() === null) {
brewser.pull({ adopt: true }).then(res => {
if (res.ok && res.data) startFrom(res.data);
else startFresh();
});
} else {
startFrom(brewser.load());
}Records API
A convenience CRUD layer over the one-blob save, treating the blob as an array
of records. Each record is auto-assigned a unique id plus createdAt /
updatedAt (epoch ms) — you never set those. Every call routes through
save(), so the instant-local + background-sync behaviour applies for free.
| Call | Returns | Notes |
|---|---|---|
brewser.put(record) | string (new id) | Adds a record; SDK stamps id/createdAt/updatedAt. |
brewser.get(id) | record or null | One record by id. |
brewser.update(id, changes) | updated record or null | Shallow-merges fields, bumps updatedAt. id and createdAt are immutable. |
brewser.remove(id) | boolean | Deletes one record. |
brewser.list(opts) | object[] | All records. opts = { sortBy: 'createdAt' | 'updatedAt' | <field>, desc: true }. |
const id = brewser.put({ name: 'Alice', score: 1200 });
brewser.update(id, { score: 1300 }); // merges, bumps updatedAt
brewser.list({ sortBy: 'score', desc: true }); // highest first
brewser.remove(id);Records and whole-save are the same storage — records just interpret the
blob as an array. Don't mix a non-array save() blob with record helpers on the
same app; if the blob isn't an array, the record helpers start from an empty
list rather than throwing.
Leaderboards API
A leaderboard is a separate entity from saves: public, ranked, cross-user.
Reach it through brewser.leaderboards.
Declare the ranking direction once at startup. The server is the source of truth and stores the direction per package.
brewser.leaderboards.config({ order: 'desc' }); // high score wins (default)
brewser.leaderboards.config({ order: 'asc' }); // low wins (e.g. lap times)| Call | Returns | Auth | Notes |
|---|---|---|---|
.config({ order }) | the leaderboards object | — | 'desc' high-wins (default) or 'asc' low-wins. Call once. |
.order() | 'desc' | 'asc' | — | The direction currently declared on the client. |
.submit(score, { name }) | Promise<{ ok, best, rank, updated, onBoard }> | signed in | Best-kept: a worse score never lowers your standing. |
.list(n = 10) | Promise<{ ok, order, count, top, me? }> | public | Top N. Works signed-out; signed in, each row carries isMe. |
.aroundMe(n = 3) | Promise<{ ok, order, count, top, me, window }> | signed in | The window of rows n above/below the user. |
.me() | Promise<{ ok, me }> | signed in | me = { rank, score } or null if unranked. |
.remove() | Promise<{ ok }> | signed in | Deletes the user's own entry. |
Each row in top / window carries rank, name, score, and isMe (only
when the caller is signed in). On submit, best is the score now stored,
updated is true if this beat the previous best, and onBoard is false when
the score fell below the visible cutoff.
brewser.leaderboards.config({ order: 'desc' });
brewser.leaderboards.submit(1200, { name: 'Alice' }).then(res => {
if (!res.ok) { /* res.reason === 'unauth' → prompt sign-in */ return; }
console.log(res.updated ? 'New best!' : 'Kept your previous best', res.rank);
});
brewser.leaderboards.list(10).then(r => {
if (!r.ok) return;
r.top.forEach(row => {
console.log(`#${row.rank} ${row.name || '(anon)'} — ${row.score}` + (row.isMe ? ' ← you' : ''));
});
});list() is safe signed-out (it just can't flag which row is "you"); everything
that writes or is user-specific — submit, aroundMe, me, remove — needs
sign-in and resolves with { ok: false, reason: 'unauth' } otherwise.
Identity & sign-in
Cross-device sync and every authenticated leaderboard call need the user signed in via Brewser Auth. Inside the Brewser player the token arrives automatically — your app doesn't manage auth. The SDK finds it in this order:
- A token you handed in via
brewser.configure({ token }). window.__brewserAuthToken(), exposed by the navigator.- A
postMessageof{ type: 'brewser-token', token }from the Brewser origin (brewser.io/play.brewser.io), which the SDK listens for.
Signed out, saves are still instant and local and will sync once the user signs
in; leaderboard list() still works. Use brewser.canSync() to reflect state in
your UI (e.g. enable a "Sync now" button only when it will do something).
Reacting to sync — onSync
Pass an onSync(status, extra) callback to surface sync state. status is one
of 'pushing', 'synced', 'offline', 'unauth', 'error':
brewser.configure({
onSync(status, extra) {
if (status === 'synced') setBadge('Saved to cloud');
if (status === 'offline') setBadge('Offline — will sync later');
if (status === 'unauth') setBadge('Signed out — saved locally only');
if (status === 'error') console.warn('Sync error', extra.error);
}
});Configuration
brewser.configure(opts) overrides any of these (merged, returns brewser):
| Option | Default | Purpose |
|---|---|---|
packageId | auto from URL | Identifies your app's save box & board. |
token | — | Hand in an auth token explicitly. |
onSync | null | Sync-status callback (see above). |
apiBase | https://brewser.io/wp-json/brewser/v1 | Save/leaderboard API root. |
nsPrefix | brewser_save_ | localStorage key prefix. |
pushDebounceMs | 1500 | Delay before a background push coalesces writes. |
Putting it together
A minimal but complete integration — load on boot, save on change, submit a score, show the top 10:
brewser.leaderboards.config({ order: 'desc' });
// Boot: prefer local, fall back to the account copy.
let state = brewser.load();
if (state === null) {
const res = await brewser.pull({ adopt: true });
state = (res.ok && res.data) || { level: 1, best: 0 };
}
render(state);
// On progress: instant local write, background account sync.
function onLevelComplete(level, score) {
state.level = level;
state.best = Math.max(state.best, score);
brewser.save(state); // returns immediately
if (brewser.canSync()) {
brewser.leaderboards.submit(score, { name: playerName })
.then(() => brewser.leaderboards.list(10))
.then(r => r.ok && renderBoard(r.top));
}
}Gotchas
- localStorage wins on read.
load()never reflects a newer server copy on its own — callpull()to bring one in. Design boot flow accordingly. pull()doesn't adopt by default. Without{ adopt: true }it only returns the server copy; local is untouched.- Best-kept scores. Re-submitting a lower score is a no-op on your standing —
don't build UI that assumes the latest submit is your current rank; read
res.best/res.rank. - Debounced pushes. Rapid
save()calls coalesce into one push afterpushDebounceMs. Usesync()when you need it up now (e.g. before a level hand-off or a "Sync now" button). - Record ids are the SDK's. Never set
id/createdAt/updatedAtyourself;update()refuses to changeidandcreatedAt.
See also
- Accounts & Sign-in — the auth the cloud parts depend on, including on the Switch.
- Manifest & Permissions — why these features need nothing declared.

Brewser Docs