Saving a game sounds simple: serialize the game state to JSON, store it, load it later. In practice, save systems are one of the trickiest parts of game development because they touch every system in your game and break in subtle ways.
Browser games have a unique challenge: your save data lives in localStorage, which the user can clear at any time, which has a 5-10MB size limit, and which doesn't sync between devices. Understanding these constraints helps you design a system that's robust despite them.
Don't save everything — save the minimum needed to reconstruct the game state. This keeps your save data small and makes it easier to add new features without breaking old saves.
What to save vs. derive:
| Save (persistent state) | Derive (computed at load) |
|---|---|
| Player position, inventory, money | UI state, animation frames |
| Crop plant time, type, tile index | Growth progress, visual state |
| Achievement unlock timestamps | Achievement descriptions, icons |
| Settings (volume, controls) | Menu state, button positions |
| World seed (if procedural) | Terrain, object placement |
Always include a version number in your save data. This is the single most important decision you'll make about your save system — it enables every future migration.
A well-structured save object looks like this:
version — integer, increment when the save format changestimestamp — when the save was created (ISO string)playtime — total seconds playedcore — player state (position, money, inventory)world — tile states, NPC positions, weatherprogress — chapter, quests, achievementssettings — audio volume, control preferencesPlayers expect auto-save. Implement it with a timer that fires every 30-60 seconds and on specific events (tab close, chapter completion, significant progress).
Use the beforeunload event to save when the player closes the tab. This is your last chance to persist progress — don't skip it. Note that beforeunload handlers must be synchronous, so localStorage.setItem() (synchronous) works but fetch() (async) is unreliable.
JSON.stringify() on a large state object can cause noticeable frame drops. If your save data is over 100KB, consider saving on a Web Worker.JSON.stringify() handles most JavaScript types, but watch out for these gotchas:
Consider using a custom serializer/deserializer pair that handles your specific types. This is cleaner than patching up data after JSON.parse().
Your save format will change as you add features. Version migrations let old saves load in new versions of the game without losing progress.
The pattern: write a migration function for each version bump. When loading, check the save's version against the current version and run each migration in sequence.
Example migration chain: v1 → v2 (add achievements), v2 → v3 (rename "gold" to "money"), v3 → v4 (add weather system). A v1 save loads by running all three migrations in order.
Never delete a migration function. A player who hasn't played in six months might have a v1 save that needs to traverse the entire chain.
localStorage data can get corrupted — browser crashes, storage quota exceeded, malformed writes. Build defenses:
Even simple games benefit from multiple save slots. Players want to experiment, start over, or share with family members. Implement slots as separate localStorage keys: farmheart_save_1, farmheart_save_2, etc.
Show metadata for each slot on the load screen: character name, playtime, last save date, chapter progress. Store this metadata separately from the full save data so you can render the slot list without parsing every save.
localStorage typically gives you 5-10MB per origin. For most games, this is plenty. But if your game generates lots of data (world maps, crafting recipes, chat logs), you might hit the limit.
| Storage API | Capacity | Sync? | Use For |
|---|---|---|---|
| localStorage | 5-10 MB | Yes | Save data, settings |
| IndexedDB | Hundreds of MB | No (async) | Large worlds, assets |
| sessionStorage | 5-10 MB | Yes | Temporary state (undo) |
For most browser games, localStorage is the right choice. If you're running into size limits, compress your save data before storing it — a simple LZ-based compression can shrink JSON saves by 60-80%.
Players want to continue on different devices. Without a full backend, you have a few options:
If you want proper cloud saves, you'll need a backend — but a serverless function that stores saves in a key-value store (Cloudflare KV, Redis) is cheap and simple to build.
Save systems are notoriously hard to test because bugs only appear after real play time. Automated tests should cover:
Add debug tools: a button to export the current save as JSON, a button to import a save, and a button to corrupt the save intentionally (for testing recovery). These tools are also invaluable for user support — when a player reports a bug, ask them to export their save.
A cozy 3D farming game that runs right in your browser. No download, no signup.
Play Now