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