Save Systems for Web Games

From localStorage one-liners to IndexedDB migrations — keeping progress safe in browsers.

Saving player progress is deceptively hard. You need: durability (no wipes on refresh), versioning (so updates don’t brick old saves), and sync safety (players can close the tab mid-save). Here’s how to design a robust browser save layer.

1. Storage Options

APIProsConsWhen to Use
localStorage 1-liner, ~5MB, sync with page reload Blocking API, limited size, no binary Simple idle/clickers, key-value saves
IndexedDB Async, 100MB+, structured, transactions Verbosity, browser quirks, needs wrapper Complex games, asset caching, migrations
File System Access API Player chooses file, true durability Chrome-only, UX friction Mod-friendly games, export/import saves

2. Quick Experiment — localStorage

// save.js
const KEY = "neon-save-v1";

export function save(state){
try {
localStorage.setItem(KEY, JSON.stringify(state));
} catch(e){ console.error("Save failed", e); }
}

export function load(){
try {
return JSON.parse(localStorage.getItem(KEY)) || {};
} catch { return {}; }
}

// example
save({ coins: 100, level: 2 });
console.log(load()); // { coins: 100, level: 2 }

**Pitfall:** It’s synchronous. Large saves can stutter the game loop. Best for ≤ 50KB JSON.

3. Experiment — IndexedDB with Wrapper

// idb.js — tiny promise wrapper
export function openDB(name="neon-db", store="saves"){
return new Promise((resolve, reject) => {
const req = indexedDB.open(name, 1);
req.onupgradeneeded = () => req.result.createObjectStore(store);
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}

export async function put(db, store, key, value){
return new Promise((res,rej) => {
const tx = db.transaction(store,"readwrite");
tx.objectStore(store).put(value,key);
tx.oncomplete = () => res();
tx.onerror = () => rej(tx.error);
});
}

export async function get(db, store, key){
return new Promise((res,rej) => {
const tx = db.transaction(store,"readonly");
const req = tx.objectStore(store).get(key);
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
}

// usage
const db = await openDB();
await put(db,"saves","slot1",{coins:250,level:3});
console.log(await get(db,"saves","slot1"));

Now you can store megabytes safely, async, without blocking your frame budget.

4. Migration Example

// migrations.js
export function migrate(state){
if(!state.schema){ state.schema = 1; }
if(state.schema === 1){
// v1 → v2: add gems currency
state.gems = 0;
state.schema = 2;
}
return state;
}

5. Offline Safety

  • Save often: every 30s or on critical events.
  • Double-buffer: write to save.tmp, then promote to save when complete.
  • Beforeunload: add a final quick save — but don’t rely on it.

6. Bonus — Export & Import

// export
const blob = new Blob([JSON.stringify(saveState)], {type:"application/json"});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "neon-save.json"; a.click();

// import
input.onchange = async e => {
const text = await e.target.files[0].text();
const data = JSON.parse(text);
loadState(data);
};

7. Checklist

  • [ ] localStorage for quick JSON saves (<50KB)
  • [ ] IndexedDB wrapper for scalable async saves
  • [ ] Schema versioning + migrate()
  • [ ] Autosave + double-buffering
  • [ ] Optional: export/import for player backups

Leave a Comment

Scroll to Top