Save-game storage
Where player saves live, how Roves picks a location, and Steam Cloud sync.
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 shipped | Save 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) |
| Console | Not 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 inlist()until something callsread()(orreadText()) 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 onlist()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 withreadJSON(orreadText/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.