$npx -y skills add Yakoub-ai/phaser4-gamedev --skill phaser-saveloadThis skill should be used when the user asks to "save game", "load game", "persist progress", "save state", "local storage", "save slot", "auto-save", "high score", "save settings", "save data", etc.
| 1 | # Phaser 4 Save & Load |
| 2 | |
| 3 | Phaser has no built-in save system. Persist game state with `localStorage` (simple, synchronous, always available) or IndexedDB (async, larger payloads). This guide covers both, plus migration, multi-slot saves, and auto-save patterns. |
| 4 | |
| 5 | ## What to Save vs. What to Reconstruct |
| 6 | |
| 7 | **Save this:** |
| 8 | - Player progress: current level, score, inventory, currency |
| 9 | - Flags: tutorials seen, areas unlocked, achievements earned |
| 10 | - Settings: volume levels, fullscreen preference, accessibility options |
| 11 | - Meta: last saved timestamp, save version number, total play time |
| 12 | |
| 13 | **Reconstruct from saved data (do not serialize directly):** |
| 14 | - Scene state — restart the scene and re-apply saved data in `create()` |
| 15 | - Entity positions — save only meaningful state (e.g., `bossDefeated: true`), not raw coordinates |
| 16 | - Physics bodies — never serializable; recreate them from saved config |
| 17 | |
| 18 | **Never save these:** |
| 19 | - `Phaser.GameObjects.*` instances (Sprites, Images, Text, etc.) |
| 20 | - `Phaser.Physics.*` bodies or worlds |
| 21 | - Scene references or anything with circular references |
| 22 | |
| 23 | ## localStorage — Simple Save |
| 24 | |
| 25 | `localStorage` is synchronous, string-only, and limited to ~5 MB. It is the correct choice for game saves. |
| 26 | |
| 27 | ```typescript |
| 28 | // Save |
| 29 | localStorage.setItem('game-save', JSON.stringify(saveData)); |
| 30 | |
| 31 | // Load |
| 32 | const raw = localStorage.getItem('game-save'); |
| 33 | const saveData: SaveData | null = raw ? JSON.parse(raw) : null; |
| 34 | |
| 35 | // Delete |
| 36 | localStorage.removeItem('game-save'); |
| 37 | |
| 38 | // Check if a save exists |
| 39 | const hasSave = localStorage.getItem('game-save') !== null; |
| 40 | ``` |
| 41 | |
| 42 | Always `JSON.parse` inside a `try/catch`. Corrupted or outdated save data will throw, and the game must recover gracefully. |
| 43 | |
| 44 | ## SaveData Interface |
| 45 | |
| 46 | Define a typed interface for your save data. Every field needs a default. |
| 47 | |
| 48 | ```typescript |
| 49 | interface SaveData { |
| 50 | version: number; // increment when save schema changes |
| 51 | level: number; // current level index (1-based) |
| 52 | score: number; // current run score |
| 53 | hiScore: number; // all-time high score |
| 54 | totalPlayTime: number; // total seconds played |
| 55 | settings: { |
| 56 | musicVolume: number; // 0–1 |
| 57 | sfxVolume: number; // 0–1 |
| 58 | fullscreen: boolean; |
| 59 | }; |
| 60 | unlockedLevels: number[]; // array of unlocked level indices |
| 61 | flags: Record<string, boolean>; // flexible flags: tutorials, events, etc. |
| 62 | lastSaved: number; // Date.now() timestamp |
| 63 | } |
| 64 | |
| 65 | const DEFAULT_SAVE: SaveData = { |
| 66 | version: 1, |
| 67 | level: 1, |
| 68 | score: 0, |
| 69 | hiScore: 0, |
| 70 | totalPlayTime: 0, |
| 71 | settings: { musicVolume: 0.7, sfxVolume: 1.0, fullscreen: false }, |
| 72 | unlockedLevels: [1], |
| 73 | flags: {}, |
| 74 | lastSaved: 0, |
| 75 | }; |
| 76 | ``` |
| 77 | |
| 78 | ## SaveManager Class |
| 79 | |
| 80 | A static SaveManager centralizes all save/load logic and handles versioning. |
| 81 | |
| 82 | ```typescript |
| 83 | const SAVE_KEY = 'my-game-v1'; |
| 84 | |
| 85 | class SaveManager { |
| 86 | /** Load save data. Returns DEFAULT_SAVE if none exists or data is corrupt. */ |
| 87 | static load(): SaveData { |
| 88 | try { |
| 89 | const raw = localStorage.getItem(SAVE_KEY); |
| 90 | if (!raw) return { ...DEFAULT_SAVE }; |
| 91 | const data = JSON.parse(raw) as SaveData; |
| 92 | return SaveManager.migrate(data); |
| 93 | } catch { |
| 94 | console.warn('SaveManager: failed to parse save data, using defaults'); |
| 95 | return { ...DEFAULT_SAVE }; |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /** Merge partial update into existing save and write to localStorage. */ |
| 100 | static save(partial: Partial<SaveData>): void { |
| 101 | const current = SaveManager.load(); |
| 102 | const updated: SaveData = { ...current, ...partial, lastSaved: Date.now() }; |
| 103 | // Always update hi-score |
| 104 | if ((partial.score ?? 0) > current.hiScore) { |
| 105 | updated.hiScore = partial.score!; |
| 106 | } |
| 107 | localStorage.setItem(SAVE_KEY, JSON.stringify(updated)); |
| 108 | } |
| 109 | |
| 110 | /** Delete all saved data. */ |
| 111 | static delete(): void { |
| 112 | localStorage.removeItem(SAVE_KEY); |
| 113 | } |
| 114 | |
| 115 | /** Check whether a save exists. */ |
| 116 | static exists(): boolean { |
| 117 | return localStorage.getItem(SAVE_KEY) !== null; |
| 118 | } |
| 119 | |
| 120 | /** Migrate old save formats to the current version. */ |
| 121 | static migrate(data: SaveData): SaveData { |
| 122 | let d = { ...data }; |
| 123 | |
| 124 | // v1 → v2: added `flags` field |
| 125 | if (d.version < 2) { |
| 126 | d.flags = {}; |
| 127 | d.version = 2; |
| 128 | } |
| 129 | // v2 → v3: added `totalPlayTime` |
| 130 | if (d.version < 3) { |
| 131 | d.totalPlayTime = 0; |
| 132 | d.version = 3; |
| 133 | } |
| 134 | // Always ensure all required fields have defaults (defensive merge) |
| 135 | return { ...DEFAULT_SAVE, ...d }; |
| 136 | } |
| 137 | } |
| 138 | ``` |
| 139 | |
| 140 | ## Multi-Slot Saves |
| 141 | |
| 142 | ```typescript |
| 143 | type SlotId = 1 | 2 | 3; |
| 144 | |
| 145 | class SlotSaveManager { |
| 146 | private static key(slot: SlotId): string { |
| 147 | return `my-game-slot-${slot}`; |
| 148 | } |
| 149 | |
| 150 | static load(slot: SlotId): SaveData { |
| 151 | try { |
| 152 | const raw = localStorage.getItem(SlotSaveManager.key(slot)); |
| 153 | if (!raw) return { ...DEFAULT_SAVE }; |
| 154 | return SaveManager.migr |