Skip to content

State Keys and Singleton Pattern

One of the most powerful features of Phaser Hooks is how state keys work as singleton instances. When you call a hook with a specific key, you’ll receive the same state instance anywhere you use that key—making state sharing between different parts of your game seamless and predictable.

When you create state with a key, Phaser Hooks stores that state instance internally. Any subsequent calls using the same key will return that exact same instance:

import { withGlobalState } from 'phaser-hooks';
export class MainMenuScene extends Phaser.Scene {
create() {
// First call - creates the state
const settings = withGlobalState(this, 'game-settings', {
soundVolume: 0.8,
musicVolume: 0.6,
});
console.log('Settings in MainMenu:', settings.get());
}
}
export class GameScene extends Phaser.Scene {
create() {
// Second call with same key - returns the SAME instance
const settings = withGlobalState(this, 'game-settings', {
soundVolume: 0.8,
musicVolume: 0.6,
});
console.log('Settings in Game:', settings.get());
// Both scenes share the exact same state instance!
}
}

Similar to React hooks, the initialState parameter is only used the first time a state is created. On subsequent calls with the same key, the initial state is ignored and the current state value is preserved.

import { withGlobalState } from 'phaser-hooks';
export class MainMenuScene extends Phaser.Scene {
create() {
// First call - creates state with initialState
const score = withGlobalState(this, 'score', 0);
score.set(100); // Update to 100
console.log('Score:', score.get()); // 100
}
}
export class GameScene extends Phaser.Scene {
create() {
// Second call - initialState (0) is IGNORED!
const score = withGlobalState(this, 'score', 0);
console.log('Score:', score.get()); // 100 (not 0!)
// The state preserved its value from MainMenuScene
}
}

This is why we recommend creating hook functions instead of calling hooks directly:

// ❌ Not Recommended - Direct calls
export class GameScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { hp: 100 });
// If you accidentally pass different initialState elsewhere,
// it can be confusing because it won't take effect
}
}
// ✅ Recommended - Hook functions
// hooks/withPlayer.ts
export function withPlayer(scene: Phaser.Scene) {
return withLocalState(scene, 'player', {
hp: 100,
maxHp: 100,
level: 1,
});
}
// Now use it consistently everywhere
export class GameScene extends Phaser.Scene {
create() {
const player = withPlayer(this);
// Initial state is always consistent and centralized
}
}

Benefits of using hook functions:

  1. Consistency - Initial state is defined in one place
  2. Type safety - TypeScript can infer types properly
  3. Maintainability - Easy to update initial state across the entire codebase
  4. Clarity - Clear what the default state should be
  5. No surprises - Impossible to accidentally pass different initial states

This singleton pattern enables:

  1. Automatic state synchronization - Changes in one place are instantly reflected everywhere
  2. Simple state sharing - No need to pass state instances around manually
  3. Consistent data - Impossible to have conflicting versions of the same state
  4. Clean architecture - Components can access shared state independently

For withLocalState, the singleton pattern works per scene and key combination:

import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {
create() {
// State is scoped to THIS scene
const player1 = withLocalState(this, 'player', { hp: 100 });
// Same key, same scene = same instance
const player2 = withLocalState(this, 'player', { hp: 100 });
console.log(player1 === player2); // true - same instance
player1.set({ hp: 90 });
console.log(player2.get().hp); // 90 - they share the same state
}
}

Each scene maintains its own set of local state instances:

import { withLocalState } from 'phaser-hooks';
export class Level1Scene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { hp: 100 });
player.set({ hp: 50 });
console.log('Level 1 HP:', player.get().hp); // 50
}
}
export class Level2Scene extends Phaser.Scene {
create() {
// Different scene = different instance (even with same key)
const player = withLocalState(this, 'player', { hp: 100 });
console.log('Level 2 HP:', player.get().hp); // 100 (fresh state)
}
}

Global State - Application-Wide Singletons

Section titled “Global State - Application-Wide Singletons”

withGlobalState creates true singletons that persist across all scenes:

import { withGlobalState } from 'phaser-hooks';
// Component 1
export class SettingsPanel {
constructor(scene: Phaser.Scene) {
const settings = withGlobalState(scene, 'settings', { volume: 0.8 });
settings.set({ volume: 0.5 });
}
}
// Component 2 - in a completely different scene
export class GameScene extends Phaser.Scene {
create() {
const settings = withGlobalState(this, 'settings', { volume: 0.8 });
console.log(settings.get().volume); // 0.5
// Same instance as SettingsPanel, even across scenes!
}
}

Key characteristics:

  • Shared across all scenes
  • Persists until explicitly cleared or game ends
  • Perfect for settings, achievements, player progress

withLocalState creates singletons scoped to a specific scene:

import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {
create() {
// Multiple components in the same scene share local state
const enemiesState = withLocalState(this, 'enemies', []);
// Both access the same state instance within this scene
this.spawnEnemy(enemiesState);
this.trackEnemies(enemiesState);
}
spawnEnemy(state: HookState<Enemy[]>) {
state.set([...state.get(), { id: 1, hp: 50 }]);
}
trackEnemies(state: HookState<Enemy[]>) {
console.log('Enemies count:', state.get().length); // 1
}
}

Key characteristics:

  • Shared within one scene only
  • Automatically cleaned up when scene is destroyed
  • Perfect for scene-specific game state, UI state, temporary data

Multiple components can access the same player state:

import { withLocalState } from 'phaser-hooks';
import type { HookState } from 'phaser-hooks';
interface PlayerState {
hp: number;
maxHp: number;
}
// Hook definition
export function withPlayer(scene: Phaser.Scene): HookState<PlayerState> {
return withLocalState(scene, 'player', {
hp: 100,
maxHp: 100,
});
}
// Health Bar Component
export class HealthBar {
constructor(scene: Phaser.Scene, x: number, y: number) {
const player = withPlayer(scene); // Gets the same instance
const text = scene.add.text(x, y, `HP: ${player.get().hp}`, {
fontSize: '20px',
});
player.on('change', (newState) => {
text.setText(`HP: ${newState.hp}`);
});
}
}
// Game Scene
export class GameScene extends Phaser.Scene {
create() {
const player = withPlayer(this); // Same instance as HealthBar
// Create health bar
new HealthBar(this, 10, 10);
// Damage player - health bar automatically updates
this.input.on('pointerdown', () => {
player.set({
...player.get(),
hp: Math.max(0, player.get().hp - 10),
});
});
}
}

Settings persist and are shared across all scenes:

import { withGlobalState } from 'phaser-hooks';
import type { HookState } from 'phaser-hooks';
interface GameSettings {
soundEnabled: boolean;
musicVolume: number;
difficulty: 'easy' | 'normal' | 'hard';
}
export function withGameSettings(scene: Phaser.Scene): HookState<GameSettings> {
return withGlobalState(scene, 'game-settings', {
soundEnabled: true,
musicVolume: 0.8,
difficulty: 'normal',
});
}
// Main Menu Scene
export class MainMenuScene extends Phaser.Scene {
create() {
const settings = withGameSettings(this);
this.add.text(100, 100, 'Settings')
.setInteractive()
.on('pointerdown', () => {
// Update settings
settings.set({
...settings.get(),
musicVolume: 0.5,
});
this.scene.start('SettingsScene');
});
}
}
// Settings Scene
export class SettingsScene extends Phaser.Scene {
create() {
const settings = withGameSettings(this); // Same instance!
console.log(settings.get().musicVolume); // 0.5
// The change made in MainMenuScene is already here
// Any changes here will also be reflected back
settings.set({
...settings.get(),
difficulty: 'hard',
});
}
}
// Game Scene
export class GameScene extends Phaser.Scene {
create() {
const settings = withGameSettings(this); // Still the same instance!
console.log(settings.get().difficulty); // 'hard'
// All changes from previous scenes are preserved
}
}

Example 3: Multiple Calls in Same Function

Section titled “Example 3: Multiple Calls in Same Function”

Even within the same function, the same key always returns the same instance:

import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {
create() {
// All three calls return the exact same instance
const score1 = withLocalState(this, 'score', 0);
const score2 = withLocalState(this, 'score', 0);
const score3 = withLocalState(this, 'score', 0);
console.log(score1 === score2); // true
console.log(score2 === score3); // true
score1.set(100);
console.log(score2.get()); // 100
console.log(score3.get()); // 100
}
}

Understanding how keys are scoped is crucial:

Hook TypeScopingShared AcrossCleanup
withGlobalStateApplication-wideAll scenesManual
withLocalStatePer sceneSingle scene onlyAutomatic on scene destroy

The actual singleton key is composed of:

  • Global state: key only
  • Local state: key + scene instance
// These are DIFFERENT instances (different scenes)
const state1 = withLocalState(scene1, 'player', { hp: 100 });
const state2 = withLocalState(scene2, 'player', { hp: 100 });
// These are the SAME instance (same key, no scene scope)
const global1 = withGlobalState(scene1, 'settings', { volume: 0.8 });
const global2 = withGlobalState(scene2, 'settings', { volume: 0.8 });

Choose clear, descriptive keys that represent what the state contains:

// Good
withLocalState(this, 'player-stats', { hp: 100, mp: 50 });
withGlobalState(this, 'game-settings', { volume: 0.8 });
withLocalState(this, 'ui-inventory', []);
// Avoid
withLocalState(this, 'data', { hp: 100, mp: 50 });
withGlobalState(this, 'state', { volume: 0.8 });

Wrap your state creation in functions for consistency:

hooks/withPlayer.ts
import { withLocalState } from 'phaser-hooks';
import type { HookState } from 'phaser-hooks';
interface Player {
hp: number;
level: number;
exp: number;
}
export function withPlayer(scene: Phaser.Scene): HookState<Player> {
return withLocalState(scene, 'player', {
hp: 100,
level: 1,
exp: 0,
});
}

This ensures:

  • Consistent keys across your codebase
  • Type safety
  • Easy refactoring
  • Clear API

For global state that’s used across many files, document it:

/**
* Global game settings shared across all scenes.
*
* Key: 'game-settings'
* Scope: Application-wide
*
* Used in:
* - MainMenuScene
* - SettingsScene
* - GameScene
* - PauseScene
*/
export function withGameSettings(scene: Phaser.Scene) {
return withGlobalState(scene, 'game-settings', {
soundEnabled: true,
musicVolume: 0.8,
difficulty: 'normal',
});
}

Remember that local state is destroyed with its scene:

export class GameScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { hp: 100 });
player.set({ hp: 50 });
// When transitioning to another scene, this state is destroyed
this.scene.start('GameOverScene');
}
}
export class GameOverScene extends Phaser.Scene {
create() {
const player = withLocalState(this, 'player', { hp: 100 });
console.log(player.get().hp); // 100 (fresh state, not 50)
}
}

If you need to persist data across scenes, use withGlobalState instead.

Pattern 1: Feature Modules with Shared State

Section titled “Pattern 1: Feature Modules with Shared State”
features/player/hooks/withPlayer.ts
export function withPlayer(scene: Phaser.Scene) {
return withLocalState(scene, 'player', defaultPlayer);
}
// features/player/components/HealthBar.ts
export class HealthBar {
constructor(scene: Phaser.Scene) {
const player = withPlayer(scene);
// ... render health bar
}
}
// features/player/components/StatsPanel.ts
export class StatsPanel {
constructor(scene: Phaser.Scene) {
const player = withPlayer(scene);
// ... render stats
}
}

Both components automatically share the same state instance.

// Use global state for cross-scene communication
export function withGameProgress(scene: Phaser.Scene) {
return withGlobalState(scene, 'game-progress', {
currentLevel: 1,
score: 0,
completedLevels: [],
});
}
// Level1Scene
export class Level1Scene extends Phaser.Scene {
shutdown() {
const progress = withGameProgress(this);
progress.set({
...progress.get(),
completedLevels: [...progress.get().completedLevels, 1],
});
}
}
// LevelSelectScene
export class LevelSelectScene extends Phaser.Scene {
create() {
const progress = withGameProgress(this);
const completed = progress.get().completedLevels;
// Show which levels are unlocked
}
}
  • Same key = Same instance: Calling a hook with the same key returns the same state instance
  • Global state: Shared across all scenes application-wide
  • Local state: Shared within a single scene, cleaned up automatically
  • Singleton pattern: Enables automatic synchronization and simple state sharing
  • Key composition: Global uses key only, Local uses key + scene

Understanding the singleton pattern is essential for effectively using Phaser Hooks and building maintainable game state architecture.