Skip to content

How to Build: Save System

Here's a nightmare scenario: a player grinds for an hour, levels up three times, buys new gear, and then accidentally closes the browser tab. Everything gone. For an idle game — a genre where the whole appeal is incremental progress — losing save data is the cardinal sin.

The save system serializes player state to localStorage, loads it back on startup, and handles schema migrations when the game updates. It's a temporary solution (server-side persistence comes later), but it needs to be rock solid for the prototype.

What Gets Saved (And What Doesn't)

This distinction matters more than you'd think. Save everything that represents permanent player progress. Don't save anything that gets regenerated anyway.

Saved:

  • Skill levels and experience points (Attack, Strength, Defence, Hitpoints)
  • Gold balance
  • Equipped gear IDs
  • Inventory contents (owned gear IDs, potion count)
  • Highest unlocked level and checkpoint list
  • Consumable threshold settings
  • Auto-retaliate preference

Not saved:

  • Current level layout (procedurally regenerated each run)
  • Enemy positions or states
  • Player position within a level
  • Camera state

The rule of thumb: if it would be annoying to lose, save it. If it gets rebuilt on level load anyway, skip it.

The SaveData Interface

Every save file starts with a version number and timestamp. The rest mirrors the player traits:

typescript
interface SaveData {
  version: number
  timestamp: number
  skills: {
    attackLevel: number; attackXp: number
    strengthLevel: number; strengthXp: number
    defenceLevel: number; defenceXp: number
    hitpointsLevel: number; hitpointsXp: number
  }
  gold: number
  equipment: { weapon: string | null; armor: string | null }
  inventory: { potions: number; ownedGear: string[] }
  progression: {
    highestUnlockedLevel: number
    unlockedCheckpoints: number[]
  }
  settings: {
    consumableEnabled: boolean
    consumableHpThreshold: number
    autoRetaliate: boolean
  }
}

The version field is critical — it's what makes schema migrations possible. Use the existing SAVE_SCHEMA_VERSION from packages/game/src/version.ts and increment it whenever this shape changes.

Serializing World State

typescript
function serialize(world: World): SaveData

Query the player entity with all relevant traits (IsPlayer, Skills, Gold, Equipment, Inventory, ConsumableSettings, AutoBattle). Read the PlayerProgression world trait. Pack everything into a SaveData object with the current version and timestamp.

One subtlety: always spread arrays and objects when building the save data. You want a snapshot, not a reference to live ECS data that might mutate before it's written to storage.

Deserializing Back to the World

typescript
function deserialize(world: World, data: SaveData): void

Query the player entity and write each trait back from the save data. A few things to handle carefully:

  • Health: Restore to full. Max HP comes from the hitpoints skill level. Don't save current HP — the player always starts a session at full health.
  • Settings: Map save field names back to trait field names (for example consumableEnabled to enabled).
  • Progression: Set the world-level PlayerProgression trait directly.

The Storage Layer

Wrap localStorage with error handling. Private browsing mode, storage full, corrupted JSON — all of these can throw, and none of them should crash the game:

typescript
function writeSave(data: SaveData): boolean
function readSave(): SaveData | null
function hasSave(): boolean

writeSave returns false on failure (log it, but don't interrupt gameplay). readSave returns null if there's no save or if parsing fails. hasSave is a quick check for the loading screen.

Use a single localStorage key like 'endless-idle-save'. Keep it simple.

Auto-Save Triggers

Save on a timer and on key gameplay events:

typescript
function setupAutoSave(world: World): void

Set up a 30-second interval timer that calls serialize and writeSave. Additionally, trigger an immediate save on:

  • Level complete (boss defeated)
  • Item purchased from vendor
  • Checkpoint unlocked
  • Player death (before level regeneration)

The timer catches everything else — skill level-ups, gold from random encounters, and so on. The event-based saves ensure that high-value moments are never lost even if the player closes the tab right after.

Loading on Startup

typescript
function loadGame(world: World): boolean

On game boot, call readSave(). If it returns null, this is a fresh game — do nothing and let the default trait values stand. If it returns data, run it through migrate() first, then deserialize(). Return true so the startup flow knows to skip the "new game" experience.

The Migration Chain

This is what keeps old saves working when the game updates. Every time SAVE_SCHEMA_VERSION increments, you write a migration function:

typescript
function migrate(data: SaveData): SaveData

Migrations chain sequentially: version 1 to 2, then 2 to 3, then 3 to current. Each step handles one version bump — adding new fields with defaults, renaming properties, restructuring nested objects.

The pattern is always the same: check data.version, apply the transform, bump the version number, check again. A while loop or a series of if blocks both work fine.

The key discipline: never delete a migration. A player who last played at version 1 needs the full chain (1 to 2 to 3 to 4...) to reach current. If you delete migration 2, that player's save is unrecoverable.

File Structure

src/core/save/
├── types.ts          # SaveData interface, version constant
├── serialize.ts      # Read world state into a SaveData object
├── deserialize.ts    # Restore world state from SaveData
├── storage.ts        # localStorage read/write with error handling
├── migrations.ts     # Version upgrade functions
└── index.ts          # Public API: save, load, hasSave

What This System Depends On

  • Player Entity — all player traits for serialization
  • Level StructurePlayerProgression trait

And with that, the prototype is persistent. Players can close the tab, come back days later, and pick up exactly where they left off. The idle dream, fully realized.