Docs
Saves
How save data survives the sandbox — and how to reach it from Godot, Unity and everything else.
The short version
Saves work. You almost certainly don't have to do anything.
If your game uses localStorage, it already syncs to the player's account. If it's a Godot or Unity build writing to user:// or PlayerPrefs, that syncs too. Read on only if you want save slots, labels, or explicit control.
Why this page exists
Your game runs in an iframe sandboxed without allow-same-origin, which puts it in an opaque origin. That's the single most important security property on this site — it's what stops an uploaded build from reading anyone's session.
It also means the browser gives your game no storage of its own. localStorage and indexedDB don't quietly fail to persist there; they throw the moment you touch them. An unguarded localStorage.getItem() at boot used to white-screen a build.
So we replace both, backed by the player's Game Trough account, and broker every read and write through the page that actually holds the session. Your game never sees a token or a user id.
What works automatically
| You write | We back it with | Notes | |---|---|---| | localStorage | The player's account | Restored synchronously before your first line runs | | sessionStorage | Memory | Works, deliberately doesn't persist — that's what sessionStorage means | | indexedDB | The player's account | Covers Emscripten's IDBFS: Godot user://, Unity PlayerPrefs, pygbag |
Signed-out players get the same APIs backed by their own browser, so nothing breaks for visitors who never log in.
Writes are batched and flushed automatically when the tab is hidden or closed.
The explicit API
window.Trough is there when you want real slots:
await Trough.save({ slot: 0, data: { level: 4, hp: 12 }, label: "Level 4" });
const save = await Trough.load({ slot: 0 }); // save.data is already parsed
await Trough.list(); // [{ slot, label, updatedAt, sizeBytes }]
await Trough.delete({ slot: 0 });
// Snapshot on a timer, plus automatically on tab-hide and page-close.
Trough.autosave(() => serializeGame(), { every: 15000 });
Trough.signedIn // false → saves live in this browser only
Trough.quotaBytes // total space for this game, across all slots
Every method returns a promise and rejects with a code you can branch on — quota_exceeded, too_large, rate_limited, not_found.
Slot names are lowercase, up to 32 characters, a–z 0–9 _ -. Numbers are fine (slot: 0). Names beginning __ are reserved.
Two tabs at once
Every save carries a revision. When you write, we check that the copy you're replacing is still the one you last saw — so a second tab of the same game can't silently wipe out progress made in the first.
You don't have to track anything; the revision is attached for you. What changes is that a write can now fail:
try {
await Trough.save({ slot: 0, data: state });
} catch (err) {
if (err.code === "conflict") {
// err.current is the save that won — already parsed alongside its metadata.
showPlayerAChoice(state, err.current);
}
}
If you genuinely mean to overwrite — a deliberate "save over this slot" — pass force:
await Trough.save({ slot: 0, data: state, force: true });
For the automatic shims, there's no game code to ask, so we stop rather than guess: syncing pauses for that tab, the running game keeps its in-memory state, and reloading picks up the copy that won. A trough:conflict event fires on window if you want to react:
window.addEventListener("trough:conflict", (e) => {
banner("Your progress is open in another tab — reload to catch up.");
});
Godot 4
Nothing to do for ordinary saves — keep using user:// and Godot's IDBFS persistence lands in the player's account:
var f = FileAccess.open("user://save.json", FileAccess.WRITE)
f.store_string(JSON.stringify(state))
f.close()
For slots and labels, reach the API through JavaScriptBridge:
var _cb # keep a reference — a freed callback never fires
var trough = JavaScriptBridge.get_interface("Trough")
func save_slot(slot: int, state: Dictionary, label: String) -> void:
trough.save(JavaScriptBridge.create_object("Object")) # or build a JS object:
JavaScriptBridge.eval("Trough.save({slot:%d,data:%s,label:%s})" % [
slot, JSON.stringify(state), JSON.stringify(label)
], true)
func load_slot(slot: int) -> void:
_cb = JavaScriptBridge.create_callback(_on_loaded)
var window = JavaScriptBridge.get_interface("window")
window.__godot_load = _cb
JavaScriptBridge.eval("Trough.load({slot:%d}).then(r => window.__godot_load(JSON.stringify(r ? r.data : null)))" % slot, true)
func _on_loaded(args) -> void:
var parsed = JSON.parse_string(args[0])
if parsed != null:
apply_state(parsed)
Export with the Web preset as normal. Cross-origin isolation is set for you, so threads and SharedArrayBuffer work.
Unity WebGL
PlayerPrefs already persists — Unity writes it through IDBFS, which we back. Call PlayerPrefs.Save() at sensible moments so Unity flushes to the filesystem; we handle the rest.
For slots, add a .jslib under Assets/Plugins/:
// Assets/Plugins/Trough.jslib
mergeInto(LibraryManager.library, {
TroughSave: function (slotPtr, dataPtr, labelPtr) {
if (!window.Trough) return;
window.Trough.save({
slot: UTF8ToString(slotPtr),
data: UTF8ToString(dataPtr),
label: UTF8ToString(labelPtr)
}).catch(function (e) { console.warn("[Trough]", e.message); });
},
TroughLoad: function (slotPtr, objPtr, methodPtr) {
var obj = UTF8ToString(objPtr), method = UTF8ToString(methodPtr);
if (!window.Trough) return;
window.Trough.load({ slot: UTF8ToString(slotPtr) })
.then(function (r) { SendMessage(obj, method, r ? JSON.stringify(r.data) : ""); })
.catch(function () { SendMessage(obj, method, ""); });
}
});
using System.Runtime.InteropServices;
public class TroughSaves : MonoBehaviour {
[DllImport("__Internal")] private static extern void TroughSave(string slot, string data, string label);
[DllImport("__Internal")] private static extern void TroughLoad(string slot, string obj, string method);
public void Save(string json) => TroughSave("0", json, "Checkpoint");
public void Load() => TroughLoad("0", gameObject.name, nameof(OnLoaded));
public void OnLoaded(string json) {
if (string.IsNullOrEmpty(json)) return; // no save yet
ApplyState(JsonUtility.FromJson<GameState>(json));
}
}
Build with Decompression Fallback on, or compression disabled.
pygbag
Files written under the persistent mount sync the same way, since pygbag is Emscripten too. window.Trough is reachable from Python via platform.window:
import platform, json
platform.window.Trough.save({"slot": 0, "data": json.dumps(state)})
Limits
- 256 KB per game for a free account, 2 MB with Trough+, counted across every slot.
- Up to 32 slots per game.
- Saves under 64 KB are restored into
localStoragesynchronously, before your code runs. Larger ones have to be fetched withTrough.load()— so keep thelocalStoragepath small and put bulk state in an explicit slot. TheindexedDBpath has no such limit; it's asynchronous by nature, so we simply holdopen()until the data arrives. - Unity's asset cache is deliberately not synced. Only databases that look like a filesystem mount are, which is where saves actually live.
What isn't saved
A "save state" in the emulator sense — a snapshot of your running game — isn't something we can do, and neither can anyone else on the web. There's no way to serialise a JS heap or reattach timers, audio nodes and WebGL contexts on the other side. Declare your own save data; that's what every platform that ships this does.
Saves not sticking?
Open the console inside your game frame. The SDK reports anything it couldn't install on window.__troughInstallFailures, and warns when a save is too large to restore synchronously. If that's empty and it still isn't working, post the output in Stack Help.