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