Reactivity and Event Handling
Phaser Hooks is not automatically reactive. When state changes, you need to manually update your scene elements (sprites, text, UI) by listening to change events. This gives you complete control over what happens when state updates.
Understanding Reactivity
Section titled “Understanding Reactivity”Phaser Hooks (Manual Reactivity):
// You must manually update scene elementsconst count = withLocalState(this, 'count', 0);const text = this.add.text(100, 100, `Count: ${count.get()}`);
// Listen to changes and update manuallycount.on('change', (newValue) => {  text.setText(`Count: ${newValue}`); // Manual update});Why Manual Reactivity?
Section titled “Why Manual Reactivity?”Manual reactivity in game development offers:
- Performance Control - Update only what you need, when you need
- Fine-grained Updates - Choose exactly which elements respond to changes
- Batching - Group multiple updates together for efficiency
- Game Logic Control - React to state changes with custom game logic
The .on() Method
Section titled “The .on() Method”The .on('change', callback) method subscribes to all state changes. Every time the state updates, your callback function is executed.
Basic Usage
Section titled “Basic Usage”import { withLocalState } from 'phaser-hooks';
export class GameScene extends Phaser.Scene {  create() {    const score = withLocalState(this, 'score', 0);
    // Listen to all changes    score.on('change', (currentState, oldState) => {      console.log('Score changed!');      console.log('Old:', oldState); // 0      console.log('New:', currentState); // 100    });
    score.set(100); // Triggers the callback    score.set(200); // Triggers again    score.set(300); // Triggers again  }}Callback Parameters
Section titled “Callback Parameters”The callback receives two parameters:
state.on('change', (currentState, oldState) => {  // currentState: The NEW state value after the update  // oldState: The PREVIOUS state value before the update});Example with object state:
const player = withLocalState(this, 'player', {  hp: 100,  level: 1,});
player.on('change', (current, old) => {  console.log('HP changed from', old.hp, 'to', current.hp);  console.log('Level changed from', old.level, 'to', current.level);});
player.patch({ hp: 90 });// Logs: "HP changed from 100 to 90"// Logs: "Level changed from 1 to 1"Using .get() Inside Callbacks
Section titled “Using .get() Inside Callbacks”You can also call .get() inside the callback to access the current state:
const player = withLocalState(this, 'player', { hp: 100, mp: 50 });
player.on('change', (current, old) => {  // Both work - they're equivalent  console.log('Current HP:', current.hp);  console.log('Current HP:', player.get().hp); // Same value
  // Use .get() if you need the most up-to-date value  // (useful if callback logic is complex)});Practical Example - Health Bar
Section titled “Practical Example - Health Bar”export class GameScene extends Phaser.Scene {  private healthBar: Phaser.GameObjects.Graphics;  private healthText: Phaser.GameObjects.Text;
  create() {    const player = withLocalState(this, 'player', {      hp: 100,      maxHp: 100,    });
    // Create visual elements    this.healthBar = this.add.graphics();    this.healthText = this.add.text(10, 10, '', { fontSize: '16px' });
    // Listen to changes and update visuals    player.on('change', (current, old) => {      // Update health bar      this.healthBar.clear();      this.healthBar.fillStyle(0xff0000);      const width = (current.hp / current.maxHp) * 200;      this.healthBar.fillRect(10, 30, width, 20);
      // Update text      this.healthText.setText(`HP: ${current.hp}/${current.maxHp}`);
      // Play sound if HP decreased      if (current.hp < old.hp) {        this.sound.play('damage');      }    });
    // Trigger initial render    player.patch({}); // or manually call the update logic once
    // Simulate damage    this.time.addEvent({      delay: 1000,      callback: () => {        player.patch((c) => ({ hp: Math.max(0, c.hp - 10) }));      },      loop: true,    });  }}The .once() Method
Section titled “The .once() Method”The .once('change', callback) method subscribes to only the first state change. After the first change, it automatically unsubscribes.
Basic Usage
Section titled “Basic Usage”export class GameScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { level: 1 });
    // Only fires on the FIRST change    player.once('change', (current, old) => {      console.log('Level up!', current.level);      this.sound.play('levelup');    });
    player.patch({ level: 2 }); // ✅ Fires callback    player.patch({ level: 3 }); // ❌ Does NOT fire    player.patch({ level: 4 }); // ❌ Does NOT fire  }}When to Use .once()
Section titled “When to Use .once()”Use .once() for one-time events:
export class GameScene extends Phaser.Scene {  create() {    const tutorial = withLocalState(this, 'tutorial', {      completed: false,    });
    // Show celebration only once    tutorial.once('change', (current) => {      if (current.completed) {        this.showCelebration();        this.unlockNextLevel();      }    });
    // First time completing tutorial    tutorial.patch({ completed: true }); // ✅ Shows celebration
    // Won't trigger again if tutorial is reset and completed again    tutorial.patch({ completed: false });    tutorial.patch({ completed: true }); // ❌ No celebration  }
  showCelebration() {    console.log('🎉 Tutorial Complete!');  }
  unlockNextLevel() {    console.log('Level 2 unlocked!');  }}Detecting First Occurrence
Section titled “Detecting First Occurrence”Perfect for detecting the first time something happens:
const achievements = withLocalState(this, 'achievements', {  firstKill: false,  firstDeath: false,  firstLevelUp: false,});
// Achievement unlocked on first killachievements.once('change', (current, old) => {  if (current.firstKill && !old.firstKill) {    this.unlockAchievement('First Blood');  }});Unsubscribing from Events
Section titled “Unsubscribing from Events”Both .on() and .once() return an unsubscribe function. Calling this function removes the listener.
The Unsubscribe Function
Section titled “The Unsubscribe Function”export class GameScene extends Phaser.Scene {  create() {    const score = withLocalState(this, 'score', 0);
    // .on() returns an unsubscribe function    const unsubscribe = score.on('change', (current) => {      console.log('Score:', current);    });
    score.set(100); // ✅ Fires callback    score.set(200); // ✅ Fires callback
    // Unsubscribe    unsubscribe();
    score.set(300); // ❌ Does NOT fire - listener was removed  }}Unsubscribing with .once()
Section titled “Unsubscribing with .once()”Even though .once() auto-unsubscribes, you can manually cancel it before it fires:
export class GameScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { level: 1 });
    const unsubscribe = player.once('change', (current) => {      console.log('This might never run');    });
    // Cancel before it fires    unsubscribe();
    player.patch({ level: 2 }); // ❌ Does NOT fire - was cancelled  }}Storing Unsubscribe in Class Properties
Section titled “Storing Unsubscribe in Class Properties”Essential pattern for scene lifecycle management:
export class GameScene extends Phaser.Scene {  private unsubscribeScore?: () => void;  private unsubscribePlayer?: () => void;
  create() {    const score = withLocalState(this, 'score', 0);    const player = withLocalState(this, 'player', { hp: 100 });
    // Store unsubscribe functions    this.unsubscribeScore = score.on('change', (current) => {      console.log('Score:', current);    });
    this.unsubscribePlayer = player.on('change', (current) => {      console.log('HP:', current.hp);    });  }
  shutdown() {    // Clean up when scene is destroyed    this.unsubscribeScore?.();    this.unsubscribePlayer?.();  }}Critical for Global State
Section titled “Critical for Global State”Local state automatically cleans up when scenes are destroyed, but global state persists:
export class GameScene extends Phaser.Scene {  private unsubscribeSettings?: () => void;
  create() {    // Global state persists across scenes    const settings = withGlobalState(this, 'settings', {      volume: 0.8,    });
    // ⚠️ This listener will persist even after scene is destroyed!    this.unsubscribeSettings = settings.on('change', (current) => {      // This code will try to access scene elements that might not exist!      this.sound.setVolume(current.volume); // ERROR if scene is destroyed    });  }
  shutdown() {    // ✅ CRITICAL: Unsubscribe from global state    this.unsubscribeSettings?.();  }}Multiple Unsubscribes
Section titled “Multiple Unsubscribes”Manage multiple subscriptions with an array:
export class GameScene extends Phaser.Scene {  private unsubscribes: Array<() => void> = [];
  create() {    const player = withLocalState(this, 'player', { hp: 100, mp: 50 });    const score = withLocalState(this, 'score', 0);    const enemies = withLocalState(this, 'enemies', []);
    // Collect all unsubscribe functions    this.unsubscribes.push(      player.on('change', (c) => this.updatePlayerUI(c)),      score.on('change', (c) => this.updateScoreUI(c)),      enemies.on('change', (c) => this.updateEnemyCount(c))    );  }
  shutdown() {    // Unsubscribe from all at once    this.unsubscribes.forEach(unsub => unsub());    this.unsubscribes = [];  }
  updatePlayerUI(player: any) {    console.log('Player:', player);  }
  updateScoreUI(score: number) {    console.log('Score:', score);  }
  updateEnemyCount(enemies: any[]) {    console.log('Enemies:', enemies.length);  }}The .off() Method
Section titled “The .off() Method”The .off(event, callback) method removes a specific listener. However, you cannot use anonymous functions with .off() - you must store the function reference.
Basic Usage
Section titled “Basic Usage”export class GameScene extends Phaser.Scene {  private onScoreChange: (current: number, old: number) => void;
  create() {    const score = withLocalState(this, 'score', 0);
    // Store function reference    this.onScoreChange = (current, old) => {      console.log('Score changed from', old, 'to', current);    };
    // Subscribe    score.on('change', this.onScoreChange);
    score.set(100); // ✅ Fires
    // Unsubscribe using .off()    score.off('change', this.onScoreChange);
    score.set(200); // ❌ Does NOT fire  }}Why Anonymous Functions Don’t Work
Section titled “Why Anonymous Functions Don’t Work”const score = withLocalState(this, 'score', 0);
// ❌ This WON'T work - different function instancesscore.on('change', (current) => {  console.log('Score:', current);});
score.off('change', (current) => {  console.log('Score:', current);});// Listener is NOT removed - different function instance!
// ✅ This WORKS - same function instanceconst callback = (current: number) => {  console.log('Score:', current);};
score.on('change', callback);score.off('change', callback); // Listener removed.off() vs Unsubscribe Function
Section titled “.off() vs Unsubscribe Function”Prefer the unsubscribe function:
// ✅ Recommended - simpler and cleanerconst unsubscribe = score.on('change', (current) => {  console.log('Score:', current);});unsubscribe(); // Easy!
// ❌ More verbose - requires storing function referenceconst callback = (current: number) => {  console.log('Score:', current);};score.on('change', callback);score.off('change', callback); // Works, but more boilerplateUse .off() when:
- You need to add/remove the same listener multiple times
- You’re managing listeners across multiple parts of your code
- You have a named function that needs to be reused
The .clearListeners() Method
Section titled “The .clearListeners() Method”The .clearListeners() method removes all listeners from a state at once.
Basic Usage
Section titled “Basic Usage”export class GameScene extends Phaser.Scene {  create() {    const player = withLocalState(this, 'player', { hp: 100 });
    // Add multiple listeners    player.on('change', (c) => console.log('Listener 1:', c.hp));    player.on('change', (c) => console.log('Listener 2:', c.hp));    player.on('change', (c) => console.log('Listener 3:', c.hp));
    player.patch({ hp: 90 });    // Logs:    // "Listener 1: 90"    // "Listener 2: 90"    // "Listener 3: 90"
    // Remove ALL listeners at once    player.clearListeners();
    player.patch({ hp: 80 }); // ❌ Nothing logs - all removed  }}Scene Cleanup Pattern
Section titled “Scene Cleanup Pattern”Perfect for cleaning up all listeners when a scene ends:
export class GameScene extends Phaser.Scene {  private playerState: HookState<PlayerData>;  private scoreState: HookState<number>;  private settingsState: HookState<Settings>;
  create() {    this.playerState = withLocalState(this, 'player', { hp: 100 });    this.scoreState = withLocalState(this, 'score', 0);    this.settingsState = withGlobalState(this, 'settings', {      volume: 0.8,    });
    // Add many listeners    this.playerState.on('change', (c) => this.updateHealthBar(c));    this.playerState.on('change', (c) => this.checkDeath(c));    this.scoreState.on('change', (c) => this.updateScoreText(c));    this.settingsState.on('change', (c) => this.applySettings(c));  }
  shutdown() {    // Clean up all at once    this.playerState?.clearListeners();    this.scoreState?.clearListeners();    this.settingsState?.clearListeners();  }
  updateHealthBar(player: PlayerData) { /* ... */ }  checkDeath(player: PlayerData) { /* ... */ }  updateScoreText(score: number) { /* ... */ }  applySettings(settings: Settings) { /* ... */ }}Global State Cleanup
Section titled “Global State Cleanup”Critical for global state to prevent errors and memory leaks:
export class MenuScene extends Phaser.Scene {  create() {    const globalSettings = withGlobalState(this, 'settings', {      volume: 0.8,      language: 'en',    });
    // Many components might add listeners    globalSettings.on('change', (c) => this.updateUI(c));    globalSettings.on('change', (c) => this.applyAudio(c));    globalSettings.on('change', (c) => this.saveToLocalStorage(c));
    // When transitioning to another scene    this.events.once('shutdown', () => {      // Remove ALL listeners to prevent issues      globalSettings.clearListeners();    });  }
  updateUI(settings: any) { /* ... */ }  applyAudio(settings: any) { /* ... */ }  saveToLocalStorage(settings: any) { /* ... */ }}Practical Examples
Section titled “Practical Examples”Example 1: Real-time Score Display
Section titled “Example 1: Real-time Score Display”export class GameScene extends Phaser.Scene {  private scoreText: Phaser.GameObjects.Text;  private unsubscribe?: () => void;
  create() {    const score = withLocalState(this, 'score', 0);
    // Create text    this.scoreText = this.add.text(10, 10, 'Score: 0', {      fontSize: '24px',      color: '#ffffff',    });
    // Update text when score changes    this.unsubscribe = score.on('change', (current, old) => {      this.scoreText.setText(`Score: ${current}`);
      // Animate if score increased      if (current > old) {        this.tweens.add({          targets: this.scoreText,          scale: 1.2,          duration: 100,          yoyo: true,        });      }    });
    // Increment score over time    this.time.addEvent({      delay: 1000,      callback: () => score.set((c) => c + 10),      loop: true,    });  }
  shutdown() {    this.unsubscribe?.();  }}Example 2: Player Health with Visual Feedback
Section titled “Example 2: Player Health with Visual Feedback”interface Player {  hp: number;  maxHp: number;  isDead: boolean;}
export class GameScene extends Phaser.Scene {  private healthBar: Phaser.GameObjects.Graphics;  private playerSprite: Phaser.GameObjects.Sprite;  private unsubscribe?: () => void;
  create() {    const player = withLocalState(this, 'player', {      hp: 100,      maxHp: 100,      isDead: false,    });
    this.playerSprite = this.add.sprite(400, 300, 'player');    this.healthBar = this.add.graphics();
    // React to health changes    this.unsubscribe = player.on('change', (current, old) => {      // Update health bar      this.updateHealthBar(current.hp, current.maxHp);
      // Flash red when damaged      if (current.hp < old.hp) {        this.cameras.main.flash(200, 255, 0, 0, false);        this.playerSprite.setTint(0xff0000);        this.time.delayedCall(200, () => {          this.playerSprite.clearTint();        });      }
      // Handle death      if (current.isDead && !old.isDead) {        this.handleDeath();      }    });
    // Trigger initial render    player.patch({});  }
  updateHealthBar(hp: number, maxHp: number) {    this.healthBar.clear();
    // Background    this.healthBar.fillStyle(0x000000);    this.healthBar.fillRect(10, 10, 204, 24);
    // Health    const width = (hp / maxHp) * 200;    const color = hp > 50 ? 0x00ff00 : hp > 25 ? 0xffff00 : 0xff0000;    this.healthBar.fillStyle(color);    this.healthBar.fillRect(12, 12, width, 20);  }
  handleDeath() {    this.playerSprite.setAlpha(0.5);    this.add.text(400, 200, 'GAME OVER', {      fontSize: '64px',      color: '#ff0000',    }).setOrigin(0.5);  }
  shutdown() {    this.unsubscribe?.();  }}Example 3: Multiple State Synchronization
Section titled “Example 3: Multiple State Synchronization”export class GameScene extends Phaser.Scene {  private unsubscribes: Array<() => void> = [];
  create() {    const player = withLocalState(this, 'player', { level: 1, exp: 0 });    const enemies = withLocalState(this, 'enemies', [] as Enemy[]);    const gameState = withLocalState(this, 'game', { paused: false });
    // Sync player level with UI    this.unsubscribes.push(      player.on('change', (current) => {        this.updatePlayerUI(current);      })    );
    // Spawn enemies when level increases    this.unsubscribes.push(      player.on('change', (current, old) => {        if (current.level > old.level) {          this.spawnEnemiesForLevel(current.level);        }      })    );
    // Update enemy counter    this.unsubscribes.push(      enemies.on('change', (current) => {        this.updateEnemyCounter(current.length);      })    );
    // Handle pause state    this.unsubscribes.push(      gameState.on('change', (current) => {        if (current.paused) {          this.scene.pause();        } else {          this.scene.resume();        }      })    );  }
  shutdown() {    this.unsubscribes.forEach(unsub => unsub());    this.unsubscribes = [];  }
  updatePlayerUI(player: any) {    console.log('Player UI updated:', player);  }
  spawnEnemiesForLevel(level: number) {    console.log('Spawning enemies for level:', level);  }
  updateEnemyCounter(count: number) {    console.log('Enemy count:', count);  }}
interface Enemy {  id: string;  hp: number;}Example 4: Global Settings Across Scenes
Section titled “Example 4: Global Settings Across Scenes”interface Settings {  volume: number;  musicEnabled: boolean;  language: string;}
export class BaseScene extends Phaser.Scene {  protected unsubscribes: Array<() => void> = [];
  create() {    const settings = withGlobalState(this, 'settings', {      volume: 0.8,      musicEnabled: true,      language: 'en',    });
    // Apply settings in every scene    this.unsubscribes.push(      settings.on('change', (current) => {        this.sound.setVolume(current.volume);
        if (current.musicEnabled) {          this.sound.resumeAll();        } else {          this.sound.pauseAll();        }      })    );
    // Apply settings immediately    settings.patch({});  }
  shutdown() {    // CRITICAL: Clean up global state listeners    this.unsubscribes.forEach(unsub => unsub());    this.unsubscribes = [];  }}
// Inherit from BaseSceneexport class MenuScene extends BaseScene {  create() {    super.create(); // Sets up settings listener
    // Menu-specific logic  }}
export class GameScene extends BaseScene {  create() {    super.create(); // Sets up settings listener
    // Game-specific logic  }}Best Practices
Section titled “Best Practices”1. Always Unsubscribe from Global State
Section titled “1. Always Unsubscribe from Global State”// ✅ ALWAYS clean up global state listenersexport class GameScene extends Phaser.Scene {  private unsubscribe?: () => void;
  create() {    const settings = withGlobalState(this, 'settings', { volume: 0.8 });    this.unsubscribe = settings.on('change', (c) => {      this.applySettings(c);    });  }
  shutdown() {    this.unsubscribe?.(); // CRITICAL  }
  applySettings(settings: any) { /* ... */ }}2. Use Arrays for Multiple Subscriptions
Section titled “2. Use Arrays for Multiple Subscriptions”// ✅ Manage multiple subscriptions cleanlyexport class GameScene extends Phaser.Scene {  private unsubscribes: Array<() => void> = [];
  create() {    this.unsubscribes.push(      state1.on('change', () => { /* ... */ }),      state2.on('change', () => { /* ... */ }),      state3.on('change', () => { /* ... */ })    );  }
  shutdown() {    this.unsubscribes.forEach(unsub => unsub());    this.unsubscribes = [];  }}3. Prefer Unsubscribe Function over .off()
Section titled “3. Prefer Unsubscribe Function over .off()”// ✅ Simpler - use unsubscribe functionconst unsubscribe = state.on('change', (c) => {  console.log(c);});unsubscribe();
// ❌ More complex - requires storing referenceconst callback = (c: any) => console.log(c);state.on('change', callback);state.off('change', callback);4. Use .once() for One-Time Events
Section titled “4. Use .once() for One-Time Events”// ✅ Perfect for one-time eventsplayer.once('change', (current) => {  if (current.level === 10) {    this.unlockAchievement('Reached Level 10');  }});
// ❌ Requires manual unsubscribeconst unsub = player.on('change', (current) => {  if (current.level === 10) {    this.unlockAchievement('Reached Level 10');    unsub(); // Have to remember to unsubscribe  }});5. Trigger Initial Render
Section titled “5. Trigger Initial Render”State listeners only fire on changes, not on creation:
const player = withLocalState(this, 'player', { hp: 100 });const text = this.add.text(10, 10, '');
player.on('change', (current) => {  text.setText(`HP: ${current.hp}`);});
// ✅ Trigger initial renderplayer.patch({}); // Empty patch triggers listener// ortext.setText(`HP: ${player.get().hp}`); // Manual initial set6. Check Old vs New State
Section titled “6. Check Old vs New State”Avoid unnecessary updates by comparing values:
player.on('change', (current, old) => {  // Only update if HP actually changed  if (current.hp !== old.hp) {    this.updateHealthBar(current.hp);  }
  // Only show level up animation if level increased  if (current.level > old.level) {    this.showLevelUpAnimation();  }});Common Mistakes
Section titled “Common Mistakes”Mistake 1: Not Unsubscribing from Global State
Section titled “Mistake 1: Not Unsubscribing from Global State”// ❌ Memory leak and potential errorsexport class GameScene extends Phaser.Scene {  create() {    const settings = withGlobalState(this, 'settings', { volume: 0.8 });
    settings.on('change', (current) => {      this.sound.setVolume(current.volume); // ERROR when scene is destroyed!    });    // No unsubscribe!  }}
// ✅ Always unsubscribeexport class GameScene extends Phaser.Scene {  private unsubscribe?: () => void;
  create() {    const settings = withGlobalState(this, 'settings', { volume: 0.8 });    this.unsubscribe = settings.on('change', (current) => {      this.sound.setVolume(current.volume);    });  }
  shutdown() {    this.unsubscribe?.();  }}Mistake 2: Using Anonymous Functions with .off()
Section titled “Mistake 2: Using Anonymous Functions with .off()”const score = withLocalState(this, 'score', 0);
// ❌ Won't work - different function instancescore.on('change', (c) => console.log(c));score.off('change', (c) => console.log(c)); // Listener NOT removed
// ✅ Store function referenceconst callback = (c: number) => console.log(c);score.on('change', callback);score.off('change', callback); // WorksMistake 3: Forgetting to Trigger Initial Render
Section titled “Mistake 3: Forgetting to Trigger Initial Render”const score = withLocalState(this, 'score', 100);const text = this.add.text(10, 10, '');
// ❌ Text starts empty!score.on('change', (current) => {  text.setText(`Score: ${current}`);});// Text won't show 100 until first change
// ✅ Trigger initial renderscore.patch({}); // or manually set oncetext.setText(`Score: ${score.get()}`);Mistake 4: Not Cleaning Up .once() When Needed
Section titled “Mistake 4: Not Cleaning Up .once() When Needed”// ❌ Scene might change before .once() firesplayer.once('change', (current) => {  this.scene.start('GameOver'); // Might error if scene already changed});
// ✅ Store and clean up if scene changes firstconst unsub = player.once('change', (current) => {  this.scene.start('GameOver');});
this.events.once('shutdown', () => {  unsub(); // Cancel if scene ends first});Summary
Section titled “Summary”Event Methods
Section titled “Event Methods”| Method | Description | Auto-unsubscribe | Returns | 
|---|---|---|---|
| .on('change', callback) | Listen to all changes | ❌ No | Unsubscribe function | 
| .once('change', callback) | Listen to first change only | ✅ Yes (after first) | Unsubscribe function | 
| .off('change', callback) | Remove specific listener | N/A | void | 
| .clearListeners() | Remove all listeners | N/A | void | 
Callback Signature
Section titled “Callback Signature”(currentState: T, oldState: T) => void- currentState: New state after update
- oldState: Previous state before update
Key Takeaways
Section titled “Key Takeaways”- Manual Reactivity - You must manually update scene elements in listeners
- Always Unsubscribe - Critical for global state to prevent errors and leaks
- Use Unsubscribe Function - Simpler than .off()
- Use .once()for One-Time Events - Automatic cleanup
- Store Subscriptions - Use arrays or properties to manage multiple listeners
- Clean Up in shutdown()- Essential for proper scene lifecycle
- Trigger Initial Render - Listeners don’t fire on state creation
Next Steps
Section titled “Next Steps”- State Keys and Singleton Pattern - Understanding state sharing
- Event Management Guide - Advanced event patterns
- HookState Interface Reference - Complete API documentation