State Keys and Singleton Pattern
Overview
Section titled “Overview”One of the most powerful features of Phaser Hooks is how state keys work as singleton instances. When you call a hook with a specific key, you’ll receive the same state instance anywhere you use that key—making state sharing between different parts of your game seamless and predictable.
How State Keys Work
Section titled “How State Keys Work”Shared State Instances
Section titled “Shared State Instances”When you create state with a key, Phaser Hooks stores that state instance internally. Any subsequent calls using the same key will return that exact same instance:
import { withGlobalState } from 'phaser-hooks';
export class MainMenuScene extends Phaser.Scene {  create() {    // First call - creates the state    const settings = withGlobalState(this, 'game-settings', {      soundVolume: 0.8,      musicVolume: 0.6,    });
    console.log('Settings in MainMenu:', settings.get());  }}
export class GameScene extends Phaser.Scene {  create() {    // Second call with same key - returns the SAME instance    const settings = withGlobalState(this, 'game-settings', {      soundVolume: 0.8,      musicVolume: 0.6,    });
    console.log('Settings in Game:', settings.get());    // Both scenes share the exact same state instance!  }}Initial State is Only Used Once
Section titled “Initial State is Only Used Once”Similar to React hooks, the initialState parameter is only used the first time a state is created. On subsequent calls with the same key, the initial state is ignored and the current state value is preserved.
import { withGlobalState } from 'phaser-hooks';
export class MainMenuScene extends Phaser.Scene {  create() {    // First call - creates state with initialState    const score = withGlobalState(this, 'score', 0);    score.set(100); // Update to 100    console.log('Score:', score.get()); // 100  }}
export class GameScene extends Phaser.Scene {  create() {    // Second call - initialState (0) is IGNORED!    const score = withGlobalState(this, 'score', 0);    console.log('Score:', score.get()); // 100 (not 0!)    // The state preserved its value from MainMenuScene  }}This is why we recommend creating hook functions instead of calling hooks directly:
// ❌ Not Recommended - Direct callsexport class GameScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { hp: 100 });    // If you accidentally pass different initialState elsewhere,    // it can be confusing because it won't take effect  }}
// ✅ Recommended - Hook functions// hooks/withPlayer.tsexport function withPlayer(scene: Phaser.Scene) {  return withLocalState(scene, 'player', {    hp: 100,    maxHp: 100,    level: 1,  });}
// Now use it consistently everywhereexport class GameScene extends Phaser.Scene {  create() {    const player = withPlayer(this);    // Initial state is always consistent and centralized  }}Benefits of using hook functions:
- Consistency - Initial state is defined in one place
- Type safety - TypeScript can infer types properly
- Maintainability - Easy to update initial state across the entire codebase
- Clarity - Clear what the default state should be
- No surprises - Impossible to accidentally pass different initial states
Why This Matters
Section titled “Why This Matters”This singleton pattern enables:
- Automatic state synchronization - Changes in one place are instantly reflected everywhere
- Simple state sharing - No need to pass state instances around manually
- Consistent data - Impossible to have conflicting versions of the same state
- Clean architecture - Components can access shared state independently
Scene-Scoped Singletons
Section titled “Scene-Scoped Singletons”For withLocalState, the singleton pattern works per scene and key combination:
import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {  create() {    // State is scoped to THIS scene    const player1 = withLocalState(this, 'player', { hp: 100 });
    // Same key, same scene = same instance    const player2 = withLocalState(this, 'player', { hp: 100 });
    console.log(player1 === player2); // true - same instance
    player1.set({ hp: 90 });    console.log(player2.get().hp); // 90 - they share the same state  }}Different Scenes, Different Instances
Section titled “Different Scenes, Different Instances”Each scene maintains its own set of local state instances:
import { withLocalState } from 'phaser-hooks';
export class Level1Scene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { hp: 100 });    player.set({ hp: 50 });    console.log('Level 1 HP:', player.get().hp); // 50  }}
export class Level2Scene extends Phaser.Scene {  create() {    // Different scene = different instance (even with same key)    const player = withLocalState(this, 'player', { hp: 100 });    console.log('Level 2 HP:', player.get().hp); // 100 (fresh state)  }}Global vs Local State Keys
Section titled “Global vs Local State Keys”Global State - Application-Wide Singletons
Section titled “Global State - Application-Wide Singletons”withGlobalState creates true singletons that persist across all scenes:
import { withGlobalState } from 'phaser-hooks';
// Component 1export class SettingsPanel {  constructor(scene: Phaser.Scene) {    const settings = withGlobalState(scene, 'settings', { volume: 0.8 });    settings.set({ volume: 0.5 });  }}
// Component 2 - in a completely different sceneexport class GameScene extends Phaser.Scene {  create() {    const settings = withGlobalState(this, 'settings', { volume: 0.8 });    console.log(settings.get().volume); // 0.5    // Same instance as SettingsPanel, even across scenes!  }}Key characteristics:
- Shared across all scenes
- Persists until explicitly cleared or game ends
- Perfect for settings, achievements, player progress
Local State - Scene-Scoped Singletons
Section titled “Local State - Scene-Scoped Singletons”withLocalState creates singletons scoped to a specific scene:
import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {  create() {    // Multiple components in the same scene share local state    const enemiesState = withLocalState(this, 'enemies', []);
    // Both access the same state instance within this scene    this.spawnEnemy(enemiesState);    this.trackEnemies(enemiesState);  }
  spawnEnemy(state: HookState<Enemy[]>) {    state.set([...state.get(), { id: 1, hp: 50 }]);  }
  trackEnemies(state: HookState<Enemy[]>) {    console.log('Enemies count:', state.get().length); // 1  }}Key characteristics:
- Shared within one scene only
- Automatically cleaned up when scene is destroyed
- Perfect for scene-specific game state, UI state, temporary data
Practical Examples
Section titled “Practical Examples”Example 1: Shared Player Health Bar
Section titled “Example 1: Shared Player Health Bar”Multiple components can access the same player state:
import { withLocalState } from 'phaser-hooks';import type { HookState } from 'phaser-hooks';
interface PlayerState {  hp: number;  maxHp: number;}
// Hook definitionexport function withPlayer(scene: Phaser.Scene): HookState<PlayerState> {  return withLocalState(scene, 'player', {    hp: 100,    maxHp: 100,  });}
// Health Bar Componentexport class HealthBar {  constructor(scene: Phaser.Scene, x: number, y: number) {    const player = withPlayer(scene); // Gets the same instance
    const text = scene.add.text(x, y, `HP: ${player.get().hp}`, {      fontSize: '20px',    });
    player.on('change', (newState) => {      text.setText(`HP: ${newState.hp}`);    });  }}
// Game Sceneexport class GameScene extends Phaser.Scene {  create() {    const player = withPlayer(this); // Same instance as HealthBar
    // Create health bar    new HealthBar(this, 10, 10);
    // Damage player - health bar automatically updates    this.input.on('pointerdown', () => {      player.set({        ...player.get(),        hp: Math.max(0, player.get().hp - 10),      });    });  }}Example 2: Global Game Settings
Section titled “Example 2: Global Game Settings”Settings persist and are shared across all scenes:
import { withGlobalState } from 'phaser-hooks';import type { HookState } from 'phaser-hooks';
interface GameSettings {  soundEnabled: boolean;  musicVolume: number;  difficulty: 'easy' | 'normal' | 'hard';}
export function withGameSettings(scene: Phaser.Scene): HookState<GameSettings> {  return withGlobalState(scene, 'game-settings', {    soundEnabled: true,    musicVolume: 0.8,    difficulty: 'normal',  });}
// Main Menu Sceneexport class MainMenuScene extends Phaser.Scene {  create() {    const settings = withGameSettings(this);
    this.add.text(100, 100, 'Settings')      .setInteractive()      .on('pointerdown', () => {        // Update settings        settings.set({          ...settings.get(),          musicVolume: 0.5,        });        this.scene.start('SettingsScene');      });  }}
// Settings Sceneexport class SettingsScene extends Phaser.Scene {  create() {    const settings = withGameSettings(this); // Same instance!
    console.log(settings.get().musicVolume); // 0.5    // The change made in MainMenuScene is already here
    // Any changes here will also be reflected back    settings.set({      ...settings.get(),      difficulty: 'hard',    });  }}
// Game Sceneexport class GameScene extends Phaser.Scene {  create() {    const settings = withGameSettings(this); // Still the same instance!
    console.log(settings.get().difficulty); // 'hard'    // All changes from previous scenes are preserved  }}Example 3: Multiple Calls in Same Function
Section titled “Example 3: Multiple Calls in Same Function”Even within the same function, the same key always returns the same instance:
import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {  create() {    // All three calls return the exact same instance    const score1 = withLocalState(this, 'score', 0);    const score2 = withLocalState(this, 'score', 0);    const score3 = withLocalState(this, 'score', 0);
    console.log(score1 === score2); // true    console.log(score2 === score3); // true
    score1.set(100);    console.log(score2.get()); // 100    console.log(score3.get()); // 100  }}Key Scoping Rules
Section titled “Key Scoping Rules”Understanding how keys are scoped is crucial:
| Hook Type | Scoping | Shared Across | Cleanup | 
|---|---|---|---|
| withGlobalState | Application-wide | All scenes | Manual | 
| withLocalState | Per scene | Single scene only | Automatic on scene destroy | 
Key Composition
Section titled “Key Composition”The actual singleton key is composed of:
- Global state: keyonly
- Local state: key+sceneinstance
// These are DIFFERENT instances (different scenes)const state1 = withLocalState(scene1, 'player', { hp: 100 });const state2 = withLocalState(scene2, 'player', { hp: 100 });
// These are the SAME instance (same key, no scene scope)const global1 = withGlobalState(scene1, 'settings', { volume: 0.8 });const global2 = withGlobalState(scene2, 'settings', { volume: 0.8 });Best Practices
Section titled “Best Practices”1. Use Descriptive Keys
Section titled “1. Use Descriptive Keys”Choose clear, descriptive keys that represent what the state contains:
// GoodwithLocalState(this, 'player-stats', { hp: 100, mp: 50 });withGlobalState(this, 'game-settings', { volume: 0.8 });withLocalState(this, 'ui-inventory', []);
// AvoidwithLocalState(this, 'data', { hp: 100, mp: 50 });withGlobalState(this, 'state', { volume: 0.8 });2. Create Hook Functions
Section titled “2. Create Hook Functions”Wrap your state creation in functions for consistency:
import { withLocalState } from 'phaser-hooks';import type { HookState } from 'phaser-hooks';
interface Player {  hp: number;  level: number;  exp: number;}
export function withPlayer(scene: Phaser.Scene): HookState<Player> {  return withLocalState(scene, 'player', {    hp: 100,    level: 1,    exp: 0,  });}This ensures:
- Consistent keys across your codebase
- Type safety
- Easy refactoring
- Clear API
3. Document Shared State
Section titled “3. Document Shared State”For global state that’s used across many files, document it:
/** * Global game settings shared across all scenes. * * Key: 'game-settings' * Scope: Application-wide * * Used in: * - MainMenuScene * - SettingsScene * - GameScene * - PauseScene */export function withGameSettings(scene: Phaser.Scene) {  return withGlobalState(scene, 'game-settings', {    soundEnabled: true,    musicVolume: 0.8,    difficulty: 'normal',  });}4. Be Careful with Scene Transitions
Section titled “4. Be Careful with Scene Transitions”Remember that local state is destroyed with its scene:
export class GameScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { hp: 100 });    player.set({ hp: 50 });
    // When transitioning to another scene, this state is destroyed    this.scene.start('GameOverScene');  }}
export class GameOverScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { hp: 100 });    console.log(player.get().hp); // 100 (fresh state, not 50)  }}If you need to persist data across scenes, use withGlobalState instead.
Common Patterns
Section titled “Common Patterns”Pattern 1: Feature Modules with Shared State
Section titled “Pattern 1: Feature Modules with Shared State”export function withPlayer(scene: Phaser.Scene) {  return withLocalState(scene, 'player', defaultPlayer);}
// features/player/components/HealthBar.tsexport class HealthBar {  constructor(scene: Phaser.Scene) {    const player = withPlayer(scene);    // ... render health bar  }}
// features/player/components/StatsPanel.tsexport class StatsPanel {  constructor(scene: Phaser.Scene) {    const player = withPlayer(scene);    // ... render stats  }}Both components automatically share the same state instance.
Pattern 2: Cross-Scene Communication
Section titled “Pattern 2: Cross-Scene Communication”// Use global state for cross-scene communicationexport function withGameProgress(scene: Phaser.Scene) {  return withGlobalState(scene, 'game-progress', {    currentLevel: 1,    score: 0,    completedLevels: [],  });}
// Level1Sceneexport class Level1Scene extends Phaser.Scene {  shutdown() {    const progress = withGameProgress(this);    progress.set({      ...progress.get(),      completedLevels: [...progress.get().completedLevels, 1],    });  }}
// LevelSelectSceneexport class LevelSelectScene extends Phaser.Scene {  create() {    const progress = withGameProgress(this);    const completed = progress.get().completedLevels;    // Show which levels are unlocked  }}Summary
Section titled “Summary”- Same key = Same instance: Calling a hook with the same key returns the same state instance
- Global state: Shared across all scenes application-wide
- Local state: Shared within a single scene, cleaned up automatically
- Singleton pattern: Enables automatic synchronization and simple state sharing
- Key composition: Global uses keyonly, Local useskey + scene
Understanding the singleton pattern is essential for effectively using Phaser Hooks and building maintainable game state architecture.
Next Steps
Section titled “Next Steps”- Local State Guide - Deep dive into scene-specific state
- Global State Guide - Learn about persistent cross-scene state
- Basic Usage - Learn the fundamentals
- API Reference - Complete API documentation