Roves
Integrations

Save-game storage

Where player saves live, how Roves picks a location, and Steam Cloud sync.

Edit on GitHub

Roves has a built-in save-data API — an async, origin-scoped key/value store your game's own code talks to, shaped like IndexedDB in spirit (the same reasoning applies: keys, async reads/writes, no server round-trip) but backed by real files on disk that Roves places somewhere sensible for you.

import { isAvailable } from "@drincs/roves-api/core";
import { saves } from "@drincs/roves-api/saves";

if (isAvailable()) {
  await saves.writeText("slot-1", JSON.stringify(gameState));
  const saved = await saves.readText("slot-1");
}

See roves-api → saves for the full API surface, and roves-api → core for isAvailable() — check that first, since a plain browser preview (or any embedder other than Roves) has no saves: handler to answer at all.

Not IndexedDB itself

Roves also ships the engine's own (unmodified, upstream) IndexedDB implementation, and it works. This API exists alongside it as the recommended path specifically because it, not IndexedDB, is the one Roves actively manages the location of and syncs to Steam Cloud — see below.

Where saves actually land

You never choose a path yourself — Roves picks one based on how the exact binary the player is running was shipped, and creates the folder for you the first time it's needed:

How the game was shippedSave location
Portable (the default mach bundle output — no installer)A saves/ folder next to the game itself
Installed (--msi / --dmg / --deb)The OS's cache directory (a sibling of the packed-content extraction cache and roves.log)
ConsoleNot implemented yet — every saves call answers "unavailable" rather than guessing at a location no console port exists to validate against

macOS specifics

For a portable macOS build, "next to the game itself" means next to the .app bundle (on the Desktop, wherever the player dragged it), never inside it — .app bundles are conventionally treated as read-only, signed artifacts, and one mounted straight from a .dmg genuinely is read-only.

This split exists because a portable game's own folder is usually the most discoverable, "just works" place for a player to find their saves (copy the whole folder to back them up, move them to another PC) — but an installed game commonly lives somewhere a player shouldn't write to (Program Files) or wouldn't think to look (/usr/lib/<package> on Linux), so Roves redirects to the OS cache directory instead. If the portable location genuinely isn't writable (e.g. a zip extracted into Program Files without admin rights), Roves falls back to the same cache-directory location an installed build would use, rather than failing outright.

Letting a player export/import a save as a file

The saves API above is for your game's own in-app save slots. For a separate "download my save" / "load a save file I was sent" feature, use the standard web platform patterns you'd already reach for on a normal website — no @drincs/roves-api call needed at all:

// Export: a standard <a download> click on a Blob
const blob = new Blob([JSON.stringify(gameState)], { type: "application/json" });
const a = document.createElement("a");
const url = URL.createObjectURL(blob);
a.href = url;
a.download = "my-save.json";
a.click();
URL.revokeObjectURL(url);

// Import: a standard <input type="file">
const input = document.createElement("input");
input.type = "file";
input.accept = "application/json";
input.onchange = async (e) => {
  const file = (e.target as HTMLInputElement).files?.[0];
  if (file) applySave(JSON.parse(await file.text()));
};
input.click();

Roves intercepts both transparently — on desktop, a save export pops a native "Save As" dialog (with a Downloads-folder default) and an import opens a native "Open" dialog there too; on Android and iOS, export writes straight into a player-visible folder (see Mobile) and import opens each platform's own native document picker. You don't need to detect which platform you're on or branch your code — the same standard web code works everywhere. Programmatic a.click() is supported on desktop as well as real pointer clicks: Roves retains the original Blob before Servo can navigate to its opaque-origin blob: URL. Packaged-binary CI verifies the complete export path by writing a deterministic Blob to disk and comparing its bytes.

On desktop the Save File dialog's filename field accepts normal keyboard typing, including layout-aware SDL text input; you can replace the suggested filename before choosing Save.

Steam Cloud sync

Build with --features steam (see Steam integration) and, whenever a Steam client is actually running, every saves.write()/saves.delete() call is automatically mirrored to Steam Cloud — nothing extra to call, and nothing to configure in the Steamworks partner dashboard either (this isn't Steam's dashboard-configured "Auto-Cloud" folder sync; it's a direct ISteamRemoteStorage read/write per save file, keyed by the same name you pass to saves).

  • Local disk is always the source of truth for reads. A saves.read()/readText() call checks disk first; there's no merge or conflict resolution to reason about.
  • A fresh install still finds existing cloud saves. If a key has no local file yet but a Steam Cloud copy exists (e.g. a player's second PC, or a reinstall), reading that key pulls the Cloud copy down and caches it locally before returning it.
  • list()/clear() are local-only. They don't enumerate cloud saves that have never been read on this machine — a save that exists only in the Cloud won't show up in list() until something calls read() (or readText()) for its exact key at least once. If your game always knows its own save-slot names up front ("slot-1", "autosave", ...) this rarely matters in practice; if it doesn't, read every expected slot at least once on startup rather than relying on list() alone to discover what exists.
  • getMostRecent() is the exception — it does see Cloud-only saves. Steam tracks a per-file timestamp for every Cloud file regardless of whether it's ever touched this machine's disk, so "which save is newest" can be answered by comparing local file mtimes against Steam's own timestamps, without downloading anything. This is what a "Continue" button should use if a player might switch machines: it correctly finds a save made somewhere else even before that save has ever been pulled down locally — list() alone cannot do this. Once you have the winning key, fetch just that one save with readJSON (or readText/read).

Verify this against a real Steam client before shipping

This sync path talks directly to the Steamworks SDK and has not yet been exercised against a real, running Steam client and an actual Cloud-enabled app — see the engine repo's own CUSTOMIZATIONS.md ("Save-game storage API" entry) for exactly what still needs hands-on verification. Test a save written on one machine actually appearing under your app's Steam Cloud files before relying on this for a real release.

On this page