DEV Community

Cover image for The Power of Dependency Injection in Phaser 3: Building a Modular Game with SOLID principles.
Belka
Belka

Posted on

The Power of Dependency Injection in Phaser 3: Building a Modular Game with SOLID principles.

Introduction

Dependency injection is a popular technique that holds huge significance in software development. It's a technique that allows developers to decouple dependencies and provide them to classes from external sources. By doing so, we can achieve modular designs, enhance code reusability, and make our applications more flexible and maintainable overall.

By default, Phaser tends to be very iterative and requires a heavily procedural approach. This can become cumbersome and lead to messy codebases that are difficult to refactor. Fortunately, by adopting best practices like SOLID right off the bat, we can start on the right foot to easily maintain and scale our codebase as it grows.

While it may seem overkill for many to incorporate production grade workflow for personal projects, I think it presents an excellent opportunity to adopt software development best practices and work with cutting-edge and relevant libraries. Whether you're starting a small project or working on a complex game, leveraging these practices will only be beneficial and facilitate the development process.

In this article, we'll explore how to leverage the power of dependency injection in Phaser game development. We'll focus on decoupling game logic, splitting it into reusable components, taking inspiration from React's component-based architecture.

The Core and Game Layers

One of the key principles in SOLID is the separation of concerns. By dividing a project into separate layers, we can achieve a more modular and maintainable codebase. In the context of Phaser, we can distinguish between two layers: the Core layer and the Game layer.

The Core layer serves as providing the foundation for the game. Its main role is to abstract away low-level implementation details. it encapsulates the essential components that are shared across various entities. The central component in the Core layer is the Kernel, which acts as the container for managing and resolving dependencies.

Kernel

The Kernel is the container responsible for binding core dependencies such as BaseScene, BaseContainer, BaseButton, etc. These dependencies provide the basic building blocks for the game, including scene management, volume/input handling, state management, etc. By centralizing these core dependencies in the Kernel, we can ensure consistency and easy access throughout the current application and even the future ones.

For example, the BaseScene class in the Core layer abstracts away the complexities of managing scenes. It provides a unified interface and a set of standardized methods that can be easily extended and customized in the Game layer. We can later create custom scenes by extending this BaseScene class and overriding the necessary methods to implement specific game logic.

Game Layer

On the other hand, the Game layer focuses on concrete implementations that are specific to the game being developed. It contains the game logic (menus, gameplay), assets, and custom entities that interact with the core modules provided by the Core layer. For example, the Game layer may include concrete implementations like MainMenuScene and SettingsButton, which utilize the core components (BaseScene, BaseButton) for managing the underlying entities.

By enforcing a one-directional flow, where the Game layer depends on the Core layer, we establish a clear separation of concerns. The Core layer acts as the backbone, providing essential services and abstractions, while the Game layer leverages these services to implement game-specific functionality. You can expand upon this concept to stack as many layers (extensions to the game) as long as you respect the direction where each layer depends only on the layer below it.

It's important to note that the boundaries between the Core and Game layers can sometimes be thin and subject to interpretation. For example, you might consider a BootScene to be part of the Core layer because it plays a crucial role in the initialization process. Ultimately, the goal is to separate concerns and create a clear division of responsibilities to enhance code organization and maintainability.

Kernel Implementation

The Kernel implementation can be done using a powerful dependency injection tool like InversifyJS. InversifyJS is a popular JavaScript library that provides an implementation of the Inversion of Control (IoC) pattern and makes dependency injection simple and straightforward. First we can create a singleton class called Kernel.

Using the Kernel as a singleton ensures that there is only one instance throughout the application. By having a single instance, the Kernel can be accessed globally from any part of the application. Any class or component that requires a dependency can access the Kernel and request the necessary instances. By centralizing the management of dependencies in the Kernel, we can easily retrieve dependencies like the GameInstanceor LoggerService.

/Injecting the CurrentScene will allow components or classes to retrieve the active scene instance from the Kernel and perform actions or retrieve information specific to the active scene./

If you are using Redux, this is also a good place to register the store to be accessible globally for your application classes.

Note: it is generally a best practice to limit the awareness of the d.i container to your application entry points. The motive is to establish a clear and controlled boundary for dependency injection.

Here's an example of how we can use InversifyJS to create the Kernel class:


// CoreIdentifiers.ts

// First we define identifiers
export const CoreIdentifiers = {
  GameInstance: Symbol.for('GameInstance'),
  CurrentScene: Symbol.for("CurrentScene"),
  BaseScene: Symbol.for('BaseScene'),
  BaseContainer: Symbol.for('BaseContainer'),
  GameStore: Symbol.for("GameStore"),
  // ...Add other core dependency identifiers
};


// Kernel.ts
import { Container, injectable } from 'inversify';
import { configureStore } from '@reduxjs/toolkit';
import CoreIdentifiers from './CoreIdentifiers';

@injectable()
export class Kernel extends Container {
  protected static _instance: Kernel;

  private constructor() {
    super({ skipBaseClassChecks: true });
    // Bind core dependencies
    this.bind<GameInstance>(CoreIdentifiers.GameInstance)
.to(GameInstance).inSingletonScope();
    const store = configureStore({
      reducer: rootReducer,
    });
    // Bind Redux store
    this.bind(Identifiers.GameStore).toConstantValue(store);
    this.bind(CoreIdentifiers.BaseScene).to(BaseScene);
    // Bind the current scene. This will be rebound on every scene change
    this.bind<BaseScene>(Identifiers.CurrentScene)
      .toConstantValue(<BaseScene>{})

    this.bind(CoreIdentifiers.BaseContainer).to(BaseContainer);
    // ... Bind other core dependencies
  }

  public static get Instance(): Kernel {
    return this._instance || (this._instance = new this());
  }
}
Enter fullscreen mode Exit fullscreen mode

Next, we will do the exact same for our Game Layer. We can create a GameContainer that will contain out game-specific dependencies.

// GameModule.ts

export class GameModule extends Container {
  protected static _instance: GameModule;
  constructor() {
    super({ skipBaseClassChecks: true });
    // Bind your game components here
}
}
Enter fullscreen mode Exit fullscreen mode

Finally, we will merge both containers before booting our game. Merging the kernel container with the game container before booting the game allows us to create a unified container that combines the core dependencies with the game-specific dependencies. Think of it like stacking the Game layer on top of the Core layer.

// index.ts

import { Kernel } from "./core/Kernel";
import { GameModule } from "./game

 Object.assign(Kernel.Instance, <Kernel>(
      Container.merge(Kernel.Instance, GameModule.Instance)
    ));

Enter fullscreen mode Exit fullscreen mode

Component-Based Architecture

Now that we have separated the Core layer and the Game layer, let's explore how we can implement a component-based architecture. This approach allows us to break down our game logic into reusable components. Components encapsulate specific behaviour or features and can be combined to create more complex game elements. To do this, we will leverage the power of dependency injection to inject necessary dependencies into our components.

Here's an example of how we can define a simple component using dependency injection. This AudioManager example will handle the audio functionality of the game, such as playing background music, sound effects, and managing the volume:


// AudioManager.ts

import { RootState } from './store/types'; // Assuming you have RootState type defined in store

@injectable()
export class AudioManager {
  private soundService: SoundService;
  private store: Store<RootState>;

  constructor(
    @inject(SoundService) private readonly soundService: SoundService,
    @inject(Identifiers.GameStore) private readonly store: Store<RootState>
  ) {}

  public playSound(soundKey: string): void {
    const state = this.store.getState();
    const sound = state.audio.sounds[soundKey]; // Assuming you have a 'sounds' slice in your Redux state

    if (sound) {
      const { name, volume } = sound;
      this.soundService.playSound(name, volume);
    } else {
      // Handle the case when the sound is not found
    }
  }
}


// SoundService.ts

@injectable()
export class SoundService {
  constructor(
    // Injecting the CurrentScene dependency
    @inject(Identifiers.CurrentScene) private scene: BaseScene
  ) {}

  public playSound(name: string, volume: number): void {
    // Use the sound name and volume to play audio using the appropriate API (e.g., Phaser sound API)
    this.scene.sound.play(name, { volume });
  }

  // Add other sound-related methods as needed
}

Enter fullscreen mode Exit fullscreen mode

This approach demonstrates the flexibility and extensibility of dependency injection. By injecting different services into the AudioManager, we can easily integrate expand the capabilities of the audio system without tightly coupling it to specific implementations. For example you could use Howler's API instead of Phaser API without affecting the functionality of the AudioManager component. This decoupling allows us to switch or extend audio libraries or services without modifying the AudioManager itself.

Furthermore, by injecting the CurrentScene dependency, the SoundService can access the current scene instance and interact with the game environment accordingly. This enables the AudioManager to play sounds specific to the current scene or respond to scene-related events.

To be able to use the AudioManager within the scene, we can use lazy property injection. This type of injection is suitable when the dependency is optional, as in this case where the audio functionality is not crucial for the scene's core functionality. Lazy injection allows us to defer the injection process until the scene is ready to use the AudioManager.

To facilitate lazy injection, a helper function called lazyInject has been created. It utilizes the inversify-inject-decorators library. . We now use lazyInject as a decorator, and we initialize audioManager with the ! non-null assertion operator to indicate that it will be defined lazily.

// LazyInject.ts

import getDecorators from 'inversify-inject-decorators';

export const lazyInject = <T>(serviceIdentifier) =>
  getDecorators(Kernel.Instance, true).lazyInject(serviceIdentifier); // Keeping the container in cache to optimize performance


// MainMenuScene.ts

export class MainMenuScene extends BaseScene {
  @lazyInject(AudioManager)
  private audioManager!: AudioManager;

  constructor() {
    super("MainMenuScene");
  }

  init() {
    // Perform scene initialization
  }

  create() {
    const soundKey = SoundKeys.BGM; // Retrieve the key from somewhere (e.g., from a data source or configuration)
    this.audioManager.playSound(soundKey); // Play the sound associated with the soundKey
  }
}

Enter fullscreen mode Exit fullscreen mode

By using lazily injecting the AudioManager into the scene, we maintain loose coupling between the scene and the audio functionality and defer the instantiation of the AudioManager until it is actually needed (in the create callback).

Conclusion

In this article, we explored the benefits and considerations of using lazy property injection with dependency injection in Phaser. By adopting dependency injection, we can decouple dependencies, enhance flexibility, and improve code maintainability. The use of lazy property injection allows us to defer the instantiation of optional dependencies until they are actually needed, optimizing performance and reducing complexity.

Top comments (0)