Appearance
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): SaveDataQuery 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): voidQuery 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
consumableEnabledtoenabled). - Progression: Set the world-level
PlayerProgressiontrait 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(): booleanwriteSave 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): voidSet 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): booleanOn 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): SaveDataMigrations 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, hasSaveWhat This System Depends On
- Player Entity — all player traits for serialization
- Level Structure —
PlayerProgressiontrait
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.