Composing Hooks
Creating Custom Hooks
Section titled “Creating Custom Hooks”You can compose your own hooks using the built-in with* hooks—similar to how custom React hooks are built. This is a powerful way to isolate logic, reuse behavior, and keep your scenes clean and focused.
Why Compose Hooks?
Section titled “Why Compose Hooks?”✅ Keeps your scene code focused on intent rather than implementation details
✅ Allows centralized validation, side effects, or formatting for specific state slices
✅ Makes it easier to refactor or share logic across scenes and systems
✅ Provides better code organization and reusability
Basic Example: Player Energy Hook
Section titled “Basic Example: Player Energy Hook”Imagine you have a complex player state:
interface PlayerAttributes {  energy: number;  stamina: number;  strength: number;  agility: number;}
const playerState = withLocalState<PlayerAttributes>(scene, 'player', {  energy: 100,  stamina: 80,  strength: 50,  agility: 40,});Instead of always accessing playerState.get().energy, create a focused hook:
function withPlayerEnergy(scene: Phaser.Scene) {  const player = withLocalState<PlayerAttributes>(scene, 'player', {    energy: 100,    stamina: 80,    strength: 50,    agility: 40,  });
  return {    get: () => player.get().energy,    set: (value: number) => {      player.set({ ...player.get(), energy: value });    },    on: (event: 'change', callback: (energy: number) => void) => {      return player.on(event, (newPlayer) => {        callback(newPlayer.energy);      });    },    once: (event: 'change', callback: (energy: number) => void) => {      return player.once(event, (newPlayer) => {        callback(newPlayer.energy);      });    },    off: (event: 'change', callback: () => void) => {      player.off(event, callback);    },    clearListeners: () => player.clearListeners(),  };}Usage in a Scene
Section titled “Usage in a Scene”export class GameScene extends Phaser.Scene {  create() {    const energy = withPlayerEnergy(this);
    console.log('Current energy:', energy.get());
    // Update energy    energy.set(energy.get() - 10);
    // Listen to energy changes    energy.on('change', (currentEnergy) => {      if (currentEnergy <= 0) {        console.warn('You are out of energy!');      }    });  }}Advanced Example: Player Stats Hook
Section titled “Advanced Example: Player Stats Hook”Create a hook that manages all player stats with validation:
interface PlayerStats {  hp: number;  maxHp: number;  mp: number;  maxMp: number;  level: number;  exp: number;}
function withPlayerStats(scene: Phaser.Scene, initialStats: PlayerStats) {  const stats = withLocalState<PlayerStats>(scene, 'playerStats', initialStats, {    validator: (value) => {      const s = value as PlayerStats;
      if (s.hp > s.maxHp) return 'HP cannot exceed max HP';      if (s.mp > s.maxMp) return 'MP cannot exceed max MP';      if (s.hp < 0) return 'HP cannot be negative';      if (s.mp < 0) return 'MP cannot be negative';      if (s.level < 1) return 'Level must be at least 1';
      return true;    },  });
  return {    // Get methods    getHp: () => stats.get().hp,    getMp: () => stats.get().mp,    getLevel: () => stats.get().level,    getAll: () => stats.get(),
    // Update methods    takeDamage: (amount: number) => {      const current = stats.get();      stats.set({        ...current,        hp: Math.max(0, current.hp - amount),      });    },
    heal: (amount: number) => {      const current = stats.get();      stats.set({        ...current,        hp: Math.min(current.maxHp, current.hp + amount),      });    },
    useMp: (amount: number) => {      const current = stats.get();      if (current.mp < amount) {        throw new Error('Not enough MP');      }      stats.set({        ...current,        mp: current.mp - amount,      });    },
    restoreMp: (amount: number) => {      const current = stats.get();      stats.set({        ...current,        mp: Math.min(current.maxMp, current.mp + amount),      });    },
    levelUp: () => {      const current = stats.get();      stats.set({        ...current,        level: current.level + 1,        maxHp: current.maxHp + 10,        maxMp: current.maxMp + 5,        hp: current.maxHp + 10,        mp: current.maxMp + 5,      });    },
    // Event methods    on: stats.on.bind(stats),    once: stats.once.bind(stats),    off: stats.off.bind(stats),    clearListeners: stats.clearListeners.bind(stats),  };}export class BattleScene extends Phaser.Scene {  create() {    const player = withPlayerStats(this, {      hp: 100,      maxHp: 100,      mp: 50,      maxMp: 50,      level: 1,      exp: 0,    });
    // Clean, semantic API    player.takeDamage(20);    console.log('HP:', player.getHp()); // 80
    player.heal(10);    console.log('HP:', player.getHp()); // 90
    try {      player.useMp(60); // Throws error - not enough MP    } catch (error) {      console.error(error.message);    }
    player.levelUp();    console.log('Level:', player.getLevel()); // 2    console.log('Max HP:', player.getAll().maxHp); // 110
    // Listen to all stat changes    player.on('change', (stats) => {      console.log('Stats updated:', stats);      this.updateUI(stats);    });  }
  updateUI(stats: PlayerStats) {    // Update game UI  }}Composing with Computed State
Section titled “Composing with Computed State”Combine computed state in your custom hooks:
function withPlayerHealth(scene: Phaser.Scene) {  const player = withLocalState(scene, 'player', {    hp: 100,    maxHp: 100,  });
  const healthPercent = withComputedState(    scene,    'healthPercent',    player,    (p) => Math.round((p.hp / p.maxHp) * 100)  );
  return {    getHp: () => player.get().hp,    getMaxHp: () => player.get().maxHp,    getPercent: () => healthPercent.get(),
    takeDamage: (amount: number) => {      const current = player.get();      player.set({        ...current,        hp: Math.max(0, current.hp - amount),      });    },
    heal: (amount: number) => {      const current = player.get();      player.set({        ...current,        hp: Math.min(current.maxHp, current.hp + amount),      });    },
    isDead: () => player.get().hp <= 0,    isLowHealth: () => healthPercent.get() < 20,
    on: player.on.bind(player),    onPercentChange: healthPercent.on.bind(healthPercent),    clearListeners: () => {      player.clearListeners();      healthPercent.clearListeners();    },  };}export class GameScene extends Phaser.Scene {  create() {    const health = withPlayerHealth(this);
    // Check health status    console.log('HP Percent:', health.getPercent() + '%');    console.log('Low health:', health.isLowHealth());
    // Listen to percentage changes    health.onPercentChange('change', (percent) => {      if (percent < 20) {        this.showLowHealthWarning();      }    });
    // Take damage    health.takeDamage(85);    console.log('Is dead:', health.isDead()); // false    console.log('HP:', health.getHp()); // 15  }
  showLowHealthWarning() {    console.warn('Low health!');  }}Composing with Persistent State
Section titled “Composing with Persistent State”Create a settings hook with localStorage persistence:
interface GameSettings {  soundVolume: number;  musicVolume: number;  difficulty: 'easy' | 'normal' | 'hard';  language: string;}
function withGameSettings() {  const settings = withPersistentState<GameSettings>(    'gameSettings',    {      soundVolume: 0.8,      musicVolume: 0.6,      difficulty: 'normal',      language: 'en',    },    'local'  );
  return {    // Getters    getSoundVolume: () => settings.get().soundVolume,    getMusicVolume: () => settings.get().musicVolume,    getDifficulty: () => settings.get().difficulty,    getLanguage: () => settings.get().language,    getAll: () => settings.get(),
    // Setters    setSoundVolume: (volume: number) => {      settings.set({        ...settings.get(),        soundVolume: Math.max(0, Math.min(1, volume)),      });    },
    setMusicVolume: (volume: number) => {      settings.set({        ...settings.get(),        musicVolume: Math.max(0, Math.min(1, volume)),      });    },
    setDifficulty: (difficulty: GameSettings['difficulty']) => {      settings.set({        ...settings.get(),        difficulty,      });    },
    setLanguage: (language: string) => {      settings.set({        ...settings.get(),        language,      });    },
    // Utility methods    resetToDefaults: () => {      settings.set({        soundVolume: 0.8,        musicVolume: 0.6,        difficulty: 'normal',        language: 'en',      });    },
    // Event methods    on: settings.on.bind(settings),    once: settings.once.bind(settings),    off: settings.off.bind(settings),    clearListeners: settings.clearListeners.bind(settings),  };}Usage Across Scenes
Section titled “Usage Across Scenes”// In any sceneexport class MenuScene extends Phaser.Scene {  create() {    const settings = withGameSettings();
    console.log('Current volume:', settings.getSoundVolume());
    // Listen to changes    settings.on('change', (newSettings) => {      console.log('Settings updated:', newSettings);      this.applySettings(newSettings);    });
    // Update settings    settings.setSoundVolume(0.5);    settings.setDifficulty('hard');  }
  applySettings(settings: GameSettings) {    // Apply to game  }}Best Practices
Section titled “Best Practices”- Single Responsibility - Each custom hook should have one clear purpose
- Consistent API - Follow a consistent naming pattern (get*, set*, is*, etc.)
- Include Cleanup - Always expose clearListeners()for proper cleanup
- Type Safety - Use TypeScript to ensure type safety
- Documentation - Document your custom hooks clearly
- Reusability - Design hooks to be reusable across different scenes
Next Steps
Section titled “Next Steps”- Debug Mode - Debug your custom hooks
- API Reference - Complete API documentation
- Validation - Add validation to custom hooks