Skip to content
PN Scripts

Games

Save slots for a browser game in localStorage: versioned data, safe loading and saving when the tab closes

  • PN Scripts
  • 8 min read

A player who loses progress in a browser game rarely comes back. It happens more often than it should: an update renames a field and old saves stop loading, storage fills up and the write fails silently, the player closes the tab in the middle of a round, or the browser clears the site's data after a week. This guide shows how our balloon game Billowpost stores three save slots and the settings in localStorage, why each part is there, and what we would add for a game that must keep saves for years. The code is shortened from the game's own save module.

#One key, one versioned record

Everything the game remembers lives under a single key as one JSON document: a version number, the last slot used, three slots and the settings, which are shared by all slots.

export const SAVE_KEY = 'pn-billowpost';
export const SAVE_VERSION = 1;

export interface SaveData {
  v: number;
  lastSlot: number;
  slots: (Slot | null)[]; // three slots, null = empty
  settings: Settings;     // volume, accessibility, controls, graphics
}

A single key keeps writes atomic: the whole state is either the old version or the new one, never half of each. Prefix the key with your studio or game name, because every game on the same origin shares one storage area, and a game portal that hosts many titles on one domain is exactly that case. The version number costs nothing today and is the only way to recognise an old save after a change in structure.

#Load defensively

Loading is where most save systems break. Reading localStorage can throw: some browsers block it in private windows or in embedded frames with strict settings. The stored text can be damaged or written by an older version. And a save from last month lacks every field you added since. The loader handles all three:

export function loadSave(): SaveData {
  let raw: string | null = null;
  try {
    raw = localStorage.getItem(SAVE_KEY);
  } catch {
    raw = null; // storage blocked: play without saving
  }
  if (!raw) return fresh();
  try {
    const d = JSON.parse(raw);
    const out = fresh();
    out.lastSlot = typeof d.lastSlot === 'number' ? d.lastSlot : -1;
    if (Array.isArray(d.slots))
      out.slots = [0, 1, 2].map((i) => (d.slots[i] ? { ...newSlot(), ...d.slots[i] } : null));
    if (d.settings) {
      out.settings = { ...out.settings, ...d.settings };
      const def = defaultBindings();
      const b = d.settings.bindings;
      out.settings.bindings = { keys: { ...def.keys, ...(b?.keys ?? {}) }, pad: { ...def.pad, ...(b?.pad ?? {}) } };
    }
    return out;
  } catch {
    return fresh();
  }
}

The key pattern is { ...newSlot(), ...stored }. A fresh slot has every field with a sensible default; the stored slot overwrites what it has. When a later version adds, say, a daily streak to the slot, an old slot loads with an empty streak instead of crashing on a missing field. Control bindings get the same treatment one level deeper, so an action added in an update gets its default key while the player's remapped keys stay. Our article on keyboard and gamepad rebinding covers that side.

#When the merge is not enough: migrations

Merging handles added fields. It does not handle a renamed field, a changed unit or a split structure. For those, the version number pays off. Before merging, run the stored data through a chain of small functions, one per version step:

const MIGRATIONS = {
  1: (d) => ({ ...d, v: 2, slots: d.slots.map((s) => s && { ...s, playTime: Math.round((s.playMs ?? 0) / 1000) }) }),
  // 2: (d) => ...,
};

function migrate(d) {
  while (d.v < SAVE_VERSION && MIGRATIONS[d.v]) d = MIGRATIONS[d.v](d);
  return d;
}

Each step is tiny and tested with a real save from that version, kept as a fixture in the test folder. Never edit an old step after release; add a new one. Billowpost is still at version 1, so its chain is empty, but the number is already in every save it writes.

#Write with a fallback

Browsers give each origin a limited localStorage, about 5 MiB in current browsers, and a write that does not fit throws. In Billowpost the big items are postcards: small JPEG pictures that the player takes at landmarks. When a write fails, the game drops the pictures and tries again, because progress matters more than souvenirs:

export function writeSave(d: SaveData): boolean {
  try {
    localStorage.setItem(SAVE_KEY, JSON.stringify(d));
    return true;
  } catch {
    try {
      for (const s of d.slots) if (s) for (const k of Object.keys(s.postcards)) s.postcards[k] = '';
      localStorage.setItem(SAVE_KEY, JSON.stringify(d));
      return true;
    } catch {
      return false;
    }
  }
}

Decide in advance what you can throw away, and keep large media out of the main record when you can. Pictures, replays and logs belong in IndexedDB, which allows much more space and stores binary data; the small progress record stays in localStorage, where it is simple and synchronous.

#Save when the player leaves

Autosave at checkpoints is not enough for a browser game, because the player can close the tab at any moment, and on a phone the browser can discard a background tab without warning. Billowpost saves the current round whenever the page is hidden and on pagehide, the last event a page reliably gets:

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    if (mode === 'play') pause();
    saveRun();
  }
});
addEventListener('pagehide', () => saveRun());

Do not rely on beforeunload or unload: they do not fire reliably on mobile, and unload also stops the page from using the back-forward cache. One rule inside saveRun is easy to overlook: it refuses to save while a mail parcel is still falling through the air. A snapshot taken mid-fall would have to store the parcel's flight, and restoring that exactly is more fragile than waiting a second for it to land. Save at states you can restore exactly; when the round is over, clear the saved run so that Continue never offers a finished round.

#Three slots, and never overwrite without asking

Slots let a family share one browser and let a player start over without losing a finished game. When the player starts a new game and every slot is taken, Billowpost carries on in the most recent one rather than replacing anything; a slot is only overwritten after the player confirms it in a dialog. Settings are kept outside the slots, because volume, text size and controls belong to the person at the keyboard, not to a saved game.

#What can still erase a save

Even a perfect save system lives in storage the browser may clear. Players clear site data. Private windows delete everything when they close. Safari's tracking prevention removes script-writable storage of a site the person has not interacted with for seven days of browser use, and games embedded in another site's page get storage separated by that site. Three measures help:

  • Ask for persistent storage with navigator.storage.persist() after the player has made some progress. Browsers that grant it will not evict the data under storage pressure.
  • Offer export and import of the save as a file in the settings, so a player can move to another browser or keep a backup.
  • When a save cannot be read, keep the raw text under a second key before starting fresh, so that a later version or a support request can still recover it.

For engines with file systems the picture is different; our article on a Godot 4 save system that survives crashes and updates covers temporary files, checksums and rotating backups, and the Godot add-on Skrinka packages that approach. Billowpost is free and listed as Coming soon in the PN Scripts games catalogue.

#Frequently asked questions

How much can a browser game store in localStorage?

About 5 MiB per origin in current browsers, shared by everything on that origin. Keep the progress record small and put pictures or replays in IndexedDB.

Why did my players lose their saves after an update?

Usually because the new version expects fields the old save does not have. Merge stored data over complete defaults and add a version number with small migration steps for renamed or changed fields.

Should a browser game save on beforeunload?

No. It is unreliable on mobile. Save when the page becomes hidden and on pagehide, and autosave at natural checkpoints.

Can the browser delete a game's save on its own?

Yes, under storage pressure, in private windows, and in Safari after seven days of browser use without interaction with the site. Request persistent storage and let players export their save as a file.

#Sources

Comments

Comments

Be the first to leave a comment.

Leave a comment

Next step

pnscripts.com/contact

Talk to the team

Ask about an article, or tell us about a project you want built. We reply within one business day.

Write to us

The PN Scripts family

Other PN Scripts sites

Hosting, games and the blog each have their own site, run by the same company.

  • pnscripts.com

    PN Scripts

    Software engineering

    Custom web, mobile and API development, plus our open-source products and plugins.

  • games.pnscripts.com

    Games

    Games and game servers

    The home for PN Scripts games: free browser games you can play right now, with game servers coming soon.

  • hosting.pnscripts.com

    Hosting

    Hosting and infrastructure

    Shared hosting, KVM VPS, dedicated servers and domains, from the same company that builds your project.

  • blog.pnscripts.com

    Blog

    Articles and field notes

    Practical articles on software development, hosting, open source and games, written by the people who build them.

    You are here