You’re developing your Phaser game and suddenly… BOOM! An impossible-to-track bug. The player state disappears between scenes, listeners aren’t removed, and memory leaks start showing up. Sound familiar?
Get ready to discover how a small library revolutionized my code and my sanity.
The Problem: State Management Chaos
If you’ve ever developed games with Phaser, you’ve probably faced the frustration of managing states. Phaser’s native system, while functional, presents several pitfalls that can turn a simple project into a nightmare of hard-to-track bugs.
1. Naming Inconsistency
Who hasn’t had those “blank” moments while programming? I confess: I’m terrible at remembering variable names and syntax. And the worst part is that Phaser’s native system offers no validation to save us from these traps.
// Scene 1
this.registry.set('user-state', { name: 'Player', level: 5 });
// Scene 2 - Oops! Typo that will only error at runtime
this.registry.set('userState', { name: 'Player', level: 6 });
The result? Hours lost hunting an invisible bug. You alternate between camelCase
in one scene and kebab-case
in another, creating duplicate states without noticing. The worst part is that this type of error is silent - the game doesn't break, it just doesn't work as expected.
2. Confusing Event System
Phaser automatically generates events in the changedata- format, which are:
- Hard to remember
- Prone to typos
- Without proper TypeScript support
// Native system - confusing and error-prone
this.registry.events.on('changedata-player-health', (parent, key, value) => {
// Callback logic
});
// Problem: how to cleanup without memory leaks?
// If you use arrow functions, you can't do .off() later
3. Confusion Between Local and Global States
// Global state - persists between scenes
this.registry.set('global-score', 100);
// Local state - cleared when changing scenes
this.data.set('local-ui', { menuOpen: false });
While both have the same contract (.set()
, .get()
), you need to constantly remember: "Is this state registry or data?". The difference between global and local is conceptual, but you have to memorize which object to use for each situation. One more thing taking up mental space that should be focused on your game's logic.
4. Zero TypeScript Support
Without proper typing, you completely lose the intellisense and type safety that TypeScript offers.
The Solution: Phaser-Hooks
Inspired by the elegance of React Hooks, phaser-hooks is a library that brought a consistent, type-safe, and intuitive API to my Phaser workflow. After using it in production, I can say: it’s impossible to go back to the native system.
Installation
$ npm i phaser-hooks
Simple and Familiar API
If you’ve used React, you’ll feel right at home. The API is intuitive and fully typed:
// Define your state type
type PlayerUI = {
health: number;
mana: number;
menuOpen: boolean;
};
// Create state with destructuring (very React-like)
const { get, set, on } = withLocalState<PlayerUI>(this, 'player-ui', {
health: 100,
mana: 50,
menuOpen: false
});
// Use with complete type safety
set({ health: 80, mana: 30, menuOpen: true }); // ✅ Validated by TypeScript
set({ helth: 80 }); // ❌ Compilation error - typo detected!
// Retrieve typed values
const currentHealth = get().health; // Complete IntelliSense
// Listen to changes
on('change', (oldValue) => {
console.log(`Health: ${oldValue.health} → ${get().health}`);
});
The library offers two main hooks with exactly the same contract:
-
withLocalState
: State isolated per scene - perfect for UI, temporary states, or data that should "reset" when changing scenes -
withGlobalState
: State shared between all scenes - ideal for score, player settings, game progress
No matter which one you choose, the API is identical. No need to memorize if it’s registry
or data
anymore - just think about the scope you want and use the corresponding hook.
A Simple Example
Let’s create a custom hook to manage player UI:
import { withLocalState, type HookState } from 'phaser-hooks';
// Define your state type
type PlayerUI = {
health: number;
mana: number;
menuOpen: boolean;
};
// Create a custom hook
export type PlayerUIHook = HookState<PlayerUI> & {
takeDamage: (damage: number) => void;
useMana: (cost: number) => void;
toggleMenu: () => void;
};
export const withPlayerUI = (scene: Phaser.Scene): PlayerUIHook => {
const { get, set, ...rest } = withLocalState<PlayerUI>(scene, 'player-ui', {
health: 100,
mana: 50,
menuOpen: false
});
return {
...rest,
get,
set,
takeDamage: (damage: number) => {
const current = get();
set({ ...current, health: Math.max(0, current.health - damage) });
},
useMana: (cost: number) => {
const current = get();
set({ ...current, mana: Math.max(0, current.mana - cost) });
},
toggleMenu: () => {
const current = get();
set({ ...current, menuOpen: !current.menuOpen });
},
};
};
// In your scene
class GameScene extends Phaser.Scene {
create() {
const playerUI = withPlayerUI(this);
// Use with complete type safety
playerUI.takeDamage(20); // ✅ Typed method
// Listen to changes
playerUI.on('change', (oldValue) => {
console.log(`Health: ${oldValue.health}`);
});
}
}
Advanced Features
1. Clean Event System
// Permanent listener
const unsubscribe = state.on('change', (oldValue) => {
console.log('State changed:', get());
});
// One-time listener
state.once('change', (oldValue) => {
console.log('First change detected:', get());
});
// Easy cleanup
unsubscribe(); // or
state.clearListeners(); // Remove all listeners
Important: The on('change')
method returns an unsubscribe function that should be called before leaving the scene to prevent memory leaks. A common practice is to use the scene's own destroy event:
create() {
const playerState = withLocalState(this, 'player', initialValue);
const unsubscribe = playerState.on('change', (oldValue) => {
console.log('State changed from:', oldValue, 'to:', playerState.get());
});
// Automatic cleanup
this.events.once('destroy', () => {
unsubscribe();
});
}
2. Advanced Custom Hooks
You can create complex hooks that encapsulate specific game logic:
// Timer hook with complete control
export const withTimer = (scene: Phaser.Scene): TimerHook => {
const { get, set, ...rest } = withGlobalState<Timer>(scene, 'TIMER', { seconds: 0 });
let timer: Phaser.Time.TimerEvent | null = null;
return {
...rest,
get,
set,
start: () => {
if (!timer) {
timer = scene.time.addEvent({
delay: 1000,
loop: true,
callback: () => set({ seconds: get().seconds + 1 }),
});
}
},
pause: () => timer?.paused && (timer.paused = true),
reset: () => {
set({ seconds: 0 });
timer?.remove(false);
timer = null;
},
};
}
// Score hook with convenient methods
export const withScore = (scene: Phaser.Scene): ScoreHook => {
const { get, set, ...rest } = withGlobalState<Score>(scene, 'SCORE', { home: 0, away: 0 });
return {
...rest,
get,
set,
reset: () => set({ home: 0, away: 0 }),
addHomeGoal: () => set({ ...get(), home: get().home + 1 }),
addAwayGoal: () => set({ ...get(), away: get().away + 1 }),
};
}
Advanced Options
Debug Mode
const state = withLocalState(scene, 'debug-state', initialValue, {
debug: true // Enables detailed console logs
});
Custom Validation
const healthState = withLocalState<number>(scene, 'health', 100, {
validator: (value) => {
const health = value as number;
return health >= 0 && health <= 100 ? true : 'Health must be between 0-100';
}
});
Advantages of Phaser-Hooks
-
✅ Complete Type Safety
Full IntelliSense in VSCode
Errors detected at compile time
Custom interfaces for each state -
✅ Consistent API
Same contract for local and global states
Standardized and intuitive naming
Unified event system -
✅ Memory Leak Prevention
clearListeners() method for easy cleanup
unsubscribe functions returned by listeners
Clear documentation about best practices -
✅ Extensibility
Custom hooks for specific logic
Feature composition
Reuse across projects -
✅ Developer Experience
Debug mode with detailed logs
Custom value validation
Clear error messages
Performance
The library is just an abstraction layer over Phaser’s native system (registry and data). There's no significant performance overhead, maintaining all the efficiency of the original engine.
Conclusion
Phaser-hooks solves real problems that every Phaser developer faces, offering:
- Productivity: Fewer bugs, faster development
- Maintainability: Cleaner and more organized code
- Scalability: Custom hooks for complex logic
- Reliability: Type safety and memory leak prevention
Personally, I’ve been using the library in my production projects — both in Coin Flick Soccer (still in development) and Smart Dots Reloaded. The difference in development experience was transformative.
If you develop games with Phaser and want a more professional and productive development experience, try phaser-hooks in your next project.
Links
GitHub: phaser-hooks
NPM: phaser-hooks
Developed with ❤️ for the Phaser community
Top comments (0)