DEV Community

Cover image for Stop Struggling with State in Phaser js: How Phaser-Hooks Will Revolutionize Your Code
Renato Cassino
Renato Cassino

Posted on

Stop Struggling with State in Phaser js: How Phaser-Hooks Will Revolutionize Your Code

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 });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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}`);
});
Enter fullscreen mode Exit fullscreen mode

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}`);
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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();
  });
}
Enter fullscreen mode Exit fullscreen mode

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 }),
  };
}
Enter fullscreen mode Exit fullscreen mode

Advanced Options

Debug Mode

const state = withLocalState(scene, 'debug-state', initialValue, {
  debug: true  // Enables detailed console logs
});
Enter fullscreen mode Exit fullscreen mode

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';
  }
});
Enter fullscreen mode Exit fullscreen mode

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)