Skip to content

Composing 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.

✅ 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

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(),
};
}
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!');
}
});
}
}

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
}
}

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!');
}
}

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),
};
}
// In any scene
export 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
}
}
  1. Single Responsibility - Each custom hook should have one clear purpose
  2. Consistent API - Follow a consistent naming pattern (get*, set*, is*, etc.)
  3. Include Cleanup - Always expose clearListeners() for proper cleanup
  4. Type Safety - Use TypeScript to ensure type safety
  5. Documentation - Document your custom hooks clearly
  6. Reusability - Design hooks to be reusable across different scenes