Skip to content

Player data

The vault is per-player, per-app save data that lives on the mesh instead of your own database: any node may hold it, but no node can read it. This guide assumes you already have an identity session — the vault is opened with one.

Open a store

js
const store = arrrNetwork.vault.open(session);
const doc = await store.load();          // {} the first time

The vault is built on WebCrypto, which browsers provide only to a secure origin: your page must be served over https (or from localhost while developing). On plain http vault.open() throws and says so.

store holds one JSON document per player, per app. load() fetches it from the player's holder nodes and returns {} the first time a player plays your game — there is nothing to migrate, no "create the row" step.

Read and write it

js
store.set('gold', 5);
store.get('gold');
store.remove('gold');
await store.save();                       // quorum write to the player's holder nodes

get/set/remove are local and synchronous — they work on the copy load() gave you. Nothing reaches the network until you call save(), which writes the whole document to the player's holder nodes and only resolves once enough of them have accepted it (see Limits and replication below). Call save() as often as your game needs to; it is a full document write, not a diff — a save() with no changes still writes a new version. Overlapping save() calls run one after another, so a set() made between two of them lands in the second.

Conflicts

Two devices — or two tabs — can both load a document, edit it, and try to save at the same time. Only one wins:

js
store.onConflict(remoteDoc => {
  // merge remoteDoc into what you have, then store.save() again
});
await store.save();   // retries once on its own; throws VaultConflict only if that retry also loses

When a holder reports that a newer version already exists, save() does not fail immediately: it re-reads the document, re-applies your mutation on top of it, and retries the write once on its own. onConflict only fires — with the remote document that won the race — if that retry also loses. Register it before you call save(); when it fires, merge the remote document with your local changes (or just take it, or just overwrite it — that is a game design decision, not a network one) and call save() again, which now throws VaultConflict if you don't handle it and the version has moved on again.

Limits and replication

  • A record is at most 256 KiB on the wire, which fits roughly 128 KiB of JSON (the document is padded to 1 KiB buckets before encryption, so a node can see roughly how big a save is without seeing what is in it).
  • 3 holder nodes, 2 needed: a write needs at least 2 of the 3 to accept it; a read is satisfied by the first 2 that answer, and a holder that answered with a stale copy gets pushed the newer one (read repair). A save survives one holder being offline.

Loading a save into a room

A room's ordered stream is the one place every client agrees on what happened and in what order, so a save is delivered through it rather than fetched separately by each peer:

js
const room = await arrrNetwork.connect(roomId, {
  appId,
  identity: session,   // the node stamps userId on this client
  save: doc,            // sent as this player's first input
  onConnect() { /* ... */ },
  onTick(frame, inputs) {
    for (const input of inputs) {
      if (input.data && input.data.arrrSave) applySave(input.clientId, input.data.arrrSave);
      else apply(input.clientId, input.data);
    }
  }
});

Passing identity makes the node stamp the connecting client's userId from the session's token, rather than trusting whatever user object the client sent. Passing save makes the SDK send it as that player's very first input right after onConnect fires, wrapped as { arrrSave: doc } so a game can recognise it by that key rather than by guessing at position in the stream. It arrives at a definite frame like any other input — through the same ordered stream as everything else — so every peer applies it at that same frame, and a late joiner gets the result from the snapshot rather than replaying it. There is no separate "load" round-trip to race against the game starting: apply it in onTick like any other input, keyed by the sender's clientId.

Reading a save from your backend

A player's save is written by the player's own client, so your server cannot decrypt it the same way — it needs the app's own key, minted once in the console (the private key is shown exactly once and never stored by ARRR):

js
// server side
import { decryptRecord, fetchPlayerRecord } from 'arrr-network/server';

// userId and recId come from the game page's session (session.userId,
// session.recId): record ids are blinded, so central cannot compute them.
const record = await fetchPlayerRecord({ centralServiceUrl, apiKey, appId, userId, recId });
if (record) {
  const doc = await decryptRecord(appPrivateKey, record);   // null record: no save yet
}

fetchPlayerRecord asks central with your API key, verifies the player's signature on what comes back, and returns the record (or null when the player has not saved yet); decryptRecord opens it with the app's private data key. A record written before the app minted its data key has no copy sealed to it and will not open.

This is for things like a leaderboard job or a moderation tool that needs to read saves in bulk; a running game should keep using vault.open() on the client.

The gap: a save is client-written

Because a save is written by the player's own client and merely verified (not interpreted) by a node, a player can edit their own save — a node checks the signature and the size, not whether 5 gold is plausible. This is the same trust boundary a client-authoritative game already has; it does not get worse by moving to the vault, but it does not get better either.

Two fixes are designed and neither is shipped yet.

Witnessed writes, for games without an economy: a per-app policy requiring a save's signature to carry co-signatures from other players in the same room, over the exact state and frame being saved, before a node accepts it.

An authority you run, for games with one. Your own process opens the record with your app data key, checks the numbers against your rules, and countersigns; a node then stores it only with both signatures. You keep no copy of the player's key and cannot forge a save they can read — you attest to one, or you refuse it. Turning it on is a per-app setting, and the default stays player-signed, because most games do not need it.

A node runs neither of these. It checks signatures and never interprets a save, which is what lets a stranger host one. The reasoning is written up in authority-and-the-log.md.

Until one of them ships, treat vault saves the way you would treat any value a client sends you — validate what you can server-side, and do not trust a save alone to gate anything that matters more than convenience.

Check it worked

js
await store.save();
location.reload();
const reloaded = await arrrNetwork.vault.open(arrrNetwork.identity.current({ appId })).load();
console.log(reloaded.gold);   // 5

Save a value, reload the page (or open it on another device signed in to the same account), and load again — the value should come back without you having run any server of your own.

Next steps

  • Identity — what a session is and what a node can and cannot see
  • Publish on arrr.fun — a game a player can open, sign in to and keep coming back to