Brewser Docs
Features

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.

CallReturnsNotes
brewser.save(data)booleandata = any JSON value. Writes local instantly, schedules a background push.
brewser.load()your data, or nullReads the local copy. Instant.
brewser.info(){ updatedAt } or nullTimestamp (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()PromiseForce an immediate push (skips the debounce) — for a "Sync now" button.
brewser.clearLocal()booleanClears the local save. Does not touch the account copy.
brewser.canSync()booleanWhether 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.

CallReturnsNotes
brewser.put(record)string (new id)Adds a record; SDK stamps id/createdAt/updatedAt.
brewser.get(id)record or nullOne record by id.
brewser.update(id, changes)updated record or nullShallow-merges fields, bumps updatedAt. id and createdAt are immutable.
brewser.remove(id)booleanDeletes 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)
CallReturnsAuthNotes
.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 inBest-kept: a worse score never lowers your standing.
.list(n = 10)Promise<{ ok, order, count, top, me? }>publicTop N. Works signed-out; signed in, each row carries isMe.
.aroundMe(n = 3)Promise<{ ok, order, count, top, me, window }>signed inThe window of rows n above/below the user.
.me()Promise<{ ok, me }>signed inme = { rank, score } or null if unranked.
.remove()Promise<{ ok }>signed inDeletes 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:

  1. A token you handed in via brewser.configure({ token }).
  2. window.__brewserAuthToken(), exposed by the navigator.
  3. A postMessage of { 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):

OptionDefaultPurpose
packageIdauto from URLIdentifies your app's save box & board.
tokenHand in an auth token explicitly.
onSyncnullSync-status callback (see above).
apiBasehttps://brewser.io/wp-json/brewser/v1Save/leaderboard API root.
nsPrefixbrewser_save_localStorage key prefix.
pushDebounceMs1500Delay 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 — call pull() 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 after pushDebounceMs. Use sync() 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/updatedAt yourself; update() refuses to change id and createdAt.

See also

On this page