Skip to content

With Persistent State

The withPersistentState hook provides state management with automatic browser storage persistence. Perfect for settings, user preferences, game progress, or any data that should survive page refreshes and browser sessions.

withPersistentState automatically:

  • Loads - Reads from localStorage/sessionStorage on initialization
  • Saves - Writes to storage on every state update
  • Syncs - Keeps state and storage in perfect sync
  • Type-safe - Full TypeScript support with JSON serialization
const settings = withPersistentState(
this,
'settings',
{ volume: 0.8, difficulty: 'normal' },
'game-settings', // localStorage key
'local' // 'local' or 'session'
);
settings.patch({ volume: 0.5 }); // Automatically saved to localStorage!
// Refresh the page...
console.log(settings.get().volume); // 0.5 (loaded from localStorage!)

Data persists forever (until manually cleared):

const progress = withPersistentState(
this,
'progress',
{ level: 1, score: 0 },
'game-progress',
'local' // Persists across browser sessions
);

Data persists only for the current tab session:

const tempData = withPersistentState(
this,
'temp',
{ currentWave: 1 },
'game-temp',
'session' // Cleared when tab is closed
);

Create reusable persistent hooks for consistent storage:

hooks/withSettings.ts
import { type HookState, withPersistentState } from 'phaser-hooks';
type Settings = {
volume: number;
musicEnabled: boolean;
sfxEnabled: boolean;
difficulty: 'easy' | 'normal' | 'hard';
language: string;
};
type SettingsState = HookState<Settings> & {
toggleMusic: () => void;
toggleSfx: () => void;
setDifficulty: (difficulty: Settings['difficulty']) => void;
adjustVolume: (delta: number) => void;
reset: () => void;
};
const DEFAULT_SETTINGS: Settings = {
volume: 0.8,
musicEnabled: true,
sfxEnabled: true,
difficulty: 'normal',
language: 'en',
};
export function withSettings(scene: Phaser.Scene): SettingsState {
const state = withPersistentState<Settings>(
scene,
'settings',
DEFAULT_SETTINGS,
'my-game-settings', // localStorage key
'local'
);
return {
...state,
toggleMusic: () => {
const current = state.get();
state.patch({ musicEnabled: !current.musicEnabled });
},
toggleSfx: () => {
const current = state.get();
state.patch({ sfxEnabled: !current.sfxEnabled });
},
setDifficulty: (difficulty: Settings['difficulty']) => {
state.patch({ difficulty });
},
adjustVolume: (delta: number) => {
const current = state.get();
const newVolume = Math.max(0, Math.min(1, current.volume + delta));
state.patch({ volume: newVolume });
},
reset: () => {
state.set(DEFAULT_SETTINGS);
},
};
}

hooks/withGameProgress.ts
import { type HookState, withPersistentState } from 'phaser-hooks';
type GameProgress = {
currentLevel: number;
unlockedLevels: number[];
highScores: Record<number, number>;
achievements: string[];
totalPlayTime: number;
};
type GameProgressState = HookState<GameProgress> & {
unlockLevel: (level: number) => void;
setHighScore: (level: number, score: number) => void;
addAchievement: (achievement: string) => void;
incrementPlayTime: (seconds: number) => void;
resetProgress: () => void;
};
const INITIAL_PROGRESS: GameProgress = {
currentLevel: 1,
unlockedLevels: [1],
highScores: {},
achievements: [],
totalPlayTime: 0,
};
export function withGameProgress(scene: Phaser.Scene): GameProgressState {
const state = withPersistentState<GameProgress>(
scene,
'progress',
INITIAL_PROGRESS,
'my-game-progress',
'local'
);
return {
...state,
unlockLevel: (level: number) => {
const current = state.get();
if (!current.unlockedLevels.includes(level)) {
state.patch({
unlockedLevels: [...current.unlockedLevels, level],
});
}
},
setHighScore: (level: number, score: number) => {
const current = state.get();
const currentHighScore = current.highScores[level] || 0;
if (score > currentHighScore) {
state.patch({
highScores: { ...current.highScores, [level]: score },
});
}
},
addAchievement: (achievement: string) => {
const current = state.get();
if (!current.achievements.includes(achievement)) {
state.patch({
achievements: [...current.achievements, achievement],
});
}
},
incrementPlayTime: (seconds: number) => {
const current = state.get();
state.patch({ totalPlayTime: current.totalPlayTime + seconds });
},
resetProgress: () => {
state.set(INITIAL_PROGRESS);
},
};
}
// GameScene.ts
import { withGameProgress } from './hooks/withGameProgress';
class GameScene extends Phaser.Scene {
private playTimeTimer?: Phaser.Time.TimerEvent;
create() {
const progress = withGameProgress(this);
// Track play time
this.playTimeTimer = this.time.addEvent({
delay: 1000,
callback: () => progress.incrementPlayTime(1),
loop: true,
});
// Complete level
this.events.on('level-complete', (level: number, score: number) => {
progress.setHighScore(level, score);
progress.unlockLevel(level + 1);
progress.patch({ currentLevel: level + 1 });
});
// Unlock achievement
this.events.on('achievement', (id: string) => {
progress.addAchievement(id);
this.showAchievementNotification(id);
});
}
shutdown() {
this.playTimeTimer?.destroy();
}
}
// LevelSelectScene.ts
class LevelSelectScene extends Phaser.Scene {
create() {
const progress = withGameProgress(this);
const current = progress.get();
// Create level buttons
for (let i = 1; i <= 10; i++) {
const isUnlocked = current.unlockedLevels.includes(i);
const highScore = current.highScores[i] || 0;
const button = this.add.text(100, i * 50, `Level ${i}`, {
color: isUnlocked ? '#ffffff' : '#666666',
});
if (isUnlocked) {
button.setInteractive();
button.on('pointerdown', () => {
progress.patch({ currentLevel: i });
this.scene.start('GameScene', { level: i });
});
}
if (highScore > 0) {
this.add.text(300, i * 50, `High Score: ${highScore}`, {
color: '#ffff00',
});
}
}
}
}

Choose meaningful storage keys to avoid conflicts:

// ❌ Generic (might conflict with other games)
withPersistentState(this, 'settings', defaultSettings, 'settings', 'local');
// ✅ Namespaced (unique to your game)
withPersistentState(this, 'settings', defaultSettings, 'my-awesome-game:settings', 'local');
// ✅ Default behavior (auto-namespaced)
withPersistentState(this, 'settings', defaultSettings); // Uses 'phaser-hooks-state:settings'

FeatureDescription
Auto-saveWrites to storage on every state update
Auto-loadReads from storage on initialization
localStoragePersists forever (until manually cleared)
sessionStoragePersists only for current tab session
JSON serializationAutomatically handles serialization/deserialization
Type-safeFull TypeScript support

const settings = withPersistentState(
this,
'settings',
{ volume: 0.8, theme: 'dark' },
'game-settings',
'local'
);
const progress = withPersistentState(
this,
'progress',
{ level: 1, score: 0 },
'game-progress',
'local'
);
const sessionData = withPersistentState(
this,
'session',
{ currentWave: 1 },
'game-session',
'session' // Cleared when tab closes
);
const profile = withPersistentState(
this,
'profile',
{ name: 'Player', avatar: 'default' },
'game-profile',
'local'
);

Only JSON-serializable data can be persisted:

// ✅ Works (JSON-serializable)
withPersistentState(this, 'data', {
number: 42,
string: 'hello',
boolean: true,
array: [1, 2, 3],
object: { nested: true },
null: null,
});
// ❌ Won't work (not JSON-serializable)
withPersistentState(this, 'data', {
function: () => {}, // Functions lost
date: new Date(), // Becomes string
regex: /test/, // Becomes {}
undefined: undefined, // Removed
});

Browsers limit storage size (~5-10MB):

try {
largeState.set(hugeObject);
} catch (error) {
console.error('Storage quota exceeded:', error);
// Handle gracefully (clear old data, show warning, etc.)
}

Some browsers disable storage in private/incognito mode. Always handle gracefully:

// The hook will fall back to in-memory storage if localStorage is unavailable
const settings = withPersistentState(this, 'settings', defaults, 'key', 'local');
// Works in private mode, but data won't persist

You can manually inspect or clear storage:

// Check what's stored
console.log(localStorage.getItem('my-game-settings'));
// Manually clear
localStorage.removeItem('my-game-settings');
// Clear all game data
Object.keys(localStorage).forEach(key => {
if (key.startsWith('my-game:')) {
localStorage.removeItem(key);
}
});

Use withPersistentState for:

  • User settings and preferences
  • Game progress and save data
  • High scores and achievements
  • Player profiles
  • Tutorial/onboarding state

Don’t use withPersistentState for:

  • Large binary data (images, audio)
  • Sensitive data (passwords, tokens)
  • Temporary scene-specific state (use withLocalState)
  • Real-time multiplayer state (use backend)

function withPersistentState<T>(
scene: Phaser.Scene,
key: string,
initialValue: T,
storageKey?: string,
storageType?: 'local' | 'session'
): HookState<T>
  • scene - The Phaser scene instance
  • key - Unique identifier for the state
  • initialValue - Default value (used if no stored value exists)
  • storageKey (optional) - Custom localStorage/sessionStorage key (defaults to phaser-hooks-state:${key})
  • storageType (optional) - 'local' (default) or 'session'

A HookState<T> that automatically syncs with browser storage.