triggair

Blog / saves

Cloud saves that resume on any device

3 min read

Cloud saves that resume on any device cover art

A player grinds for an hour on their phone on the bus, gets home, opens your game on their laptop, and... it's a fresh start. That's the moment they quit. The fix is "cloud saves," which traditionally means standing up a database, bolting on auth so saves can't be stolen or overwritten, handling the player who was offline in a tunnel, and resolving conflicts when two devices both wrote. It's a lot of backend for what feels like it should be save() and load().

With Triggair it is save() and load().

Put and get

Saves are keyed by a slot name. Put an object in, get it back out, and the object round-trips as-is.

ts
await tg.saves.put('main', {
  level: 12,
  gold: 3400,
  inventory: ['sword', 'torch'],
});

try {
  const save = await tg.saves.get('main');
  resumeGame(save.data); // { level: 12, gold: 3400, ... }
} catch (err) {
  // get() throws a not_found TriggairError when the slot is empty
  if (err.code === 'not_found') startNewGame();
  else throw err;
}

get returns { data }, or throws a not_found TriggairError when the slot is empty. That thrown not_found is your "new player" signal: catch it and start a fresh game. Identity is already handled: because the player logged in with tg.login(), the save is bound to them and follows them to any device they sign in on. There's no auth for you to build.

Multiple slots

Slots are just names, so manual saves, autosaves, and named checkpoints are free.

ts
await tg.saves.put('autosave', state);
await tg.saves.put('slot_1', state);   // player's manual save
await tg.saves.put('checkpoint_boss', state);

const auto = await tg.saves.get('autosave');

Offline tolerance and last-write-wins

Here's the part you'd dread building. Saves are last-write-wins and offline-tolerant: for a write that must survive a dropped connection, use queue instead of put. queue hands the write to a durable outbox that flushes automatically on reconnect, so it works whether the network is up or not. (put is an immediate network write and rejects when offline; queue is fire-and-forget and returns void.)

ts
// On a flaky connection or fully offline, use queue().
// The write is queued durably and syncs when the tunnel ends.
tg.saves.queue('main', state);

Last-write-wins means the most recent put is the source of truth. Save the whole current state each time rather than diffs, so a single flushed write always represents a complete, playable save.

Save-on-change for idle games

Idle and incremental games have a special need: they accrue progress while the tab is closed. Stamp each save with a lastSeen timestamp, and on load, compute the offline earnings from the gap.

ts
async function saveNow(state: GameState) {
  await tg.saves.put('main', { ...state, lastSeen: Date.now() });
}

async function loadAndCatchUp() {
  let save;
  try {
    save = await tg.saves.get('main');
  } catch (err) {
    if (err.code === 'not_found') return startNewGame();
    throw err;
  }

  const { lastSeen, ...state } = save.data;
  const offlineMs = Date.now() - (lastSeen ?? Date.now());
  return applyOfflineEarnings(state, offlineMs);
}

// Save on meaningful change, debounced, not every frame.
let pending: ReturnType<typeof setTimeout>;
function onStateChange(state: GameState) {
  clearTimeout(pending);
  pending = setTimeout(() => saveNow(state), 1000);
}

Debouncing on change (rather than a fixed interval) keeps writes cheap while guaranteeing the latest state is captured shortly after anything happens.

Gotchas

  • Save whole snapshots, not deltas. Last-write-wins replaces the slot, so a partial object overwrites the full one.
  • Don't save every frame. Debounce meaningful changes; the outbox handles delivery, but you still control how often you write.
  • get throws not_found on an empty slot. Catch it, since that's your new-vs-returning player fork.

Two methods, and your players' progress follows them across devices and survives a dead connection, with no database to run and no auth to write. Add the put to your existing save trigger and every player picks up right where they left off.

Build this into your game.

Everything above is one import and a publishable key away. Start with the quickstart, or read the guide for the exact feature.

Keep reading