Dynamically Loading Systems in Your Babylon.js Game

Let's talk about dynamically loading systems in your Babylon.js games. This approach offers a ton of benefits, making your game code more modular, scalable, and flexible.

My Approach: Entity Component System (ECS) & Scene Management

We'll be building a SceneManager class that handles everything related to loading, switching, and unloading game scenes. The key to our system is an Entity Component System (ECS) architecture. This pattern separates your game data and logic into distinct components. Think of it like this:

  • Entities: These are the core objects in your game, like characters, items, or enemies.
  • Components: They represent the properties and behaviors of your entities, such as position, health, or movement abilities.
  • Systems: These are the logic that acts on entities based on their components. For instance, a "MovementSystem" could update the position of entities with a "PositionComponent."

The Scene Manager

Let's start by defining our SceneManager class. This class will be responsible for managing our scenes, loading their data, and handling transitions between them. You can think of a Scene as a single .json file for a level in a game, so switching scenes would happen at the end of a level.

import { Scene } from "@babylonjs/core";
import { EntityManager } from "./Entity";

export class SceneManager {
  public currentScene: Scene; // The currently active scene.
  entityManager: EntityManager; // Our entity manager, responsible for entities and components.
  scenes: Map<Scene>; // A map to store loaded scenes for easy access.
  sceneCode = { systems: [], componentTypes: [], entities: [] }; // Data about the loaded scene: its components, systems, and entities.

  constructor(entityManager: EntityManager) {
    this.scenes = {};
    this.entityManager = entityManager;
    this.currentScene = entityManager.scene;
    return this;
  }

  async loadSceneData(
    sceneName: string,
    gameName: string,
    onSceneLoaded = null,
  ) {
    // ... (Implementation follows)
  }

  switchToScene(sceneName) {
    // ... (Implementation follows)
  }

  unloadScene(sceneName: string) {
    // ... (Implementation follows)
  }

  async loadSceneCode(entityManager: EntityManager, data: any) {
    // ... (Implementation follows)
  }

  async importComponent(componentType) {
    // ... (Implementation follows)
  }

  updateSystems(deltaTime) {
    // ... (Implementation follows)
  }

  deriveRequiredSystems(componentTypes) {
    // ... (Implementation follows)
  }
}

Loading Scene Data

The loadSceneData function fetches the scene's data from a JSON file. This data includes information about the entities in the scene, their components, and the systems that manage them.

  async loadSceneData(
    sceneName: string,
    gameName: string,
    onSceneLoaded = null,
  ) {
    // If the scene is already loaded, just switch to it.
    if (this.scenes[sceneName]) {
      this.switchToScene(sceneName);
      return;
    }

    const response = await fetch(
      `/gamedata/${gameName}/scenes/${sceneName}.json`,
      {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      },
    ); // Replace "" with path to scenes
    const sceneData = await response.json();

    // Load scene resources (e.g., meshes, textures, sounds)
    if (sceneData)
      this.sceneCode = await this.loadSceneCode(this.entityManager, sceneData);
    this.scenes[sceneName] = this.currentScene;

    // Execute the scene's ready logic.
    this.currentScene.executeWhenReady(() => {
      if (onSceneLoaded) {
        onSceneLoaded(scene);
      }
    });
  }

Switching Scenes

The switchToScene function handles the transition from one scene to another. It cleans up the current scene, detaching controls and resources, and then switches to the new scene, attaching the necessary resources and controls.

  switchToScene(sceneName) {
    // Make sure the scene is actually loaded.
    if (!this.scenes[sceneName]) {
      console.error(`Scene '${sceneName}' not loaded yet.`);
      return;
    }

    // Clean up the previous scene.
    if (this.currentScene) {
      this.currentScene.detachControl();
      // Cleanup other resources as needed
    }

    // Switch to the new scene.
    this.currentScene = this.scenes[sceneName];

    // Attach input and other resources to the new scene.
    this.currentScene.attachControl(this.engine.getRenderingCanvas());
    // Handle other resource attachments as needed (e.g., cameras)

    // Implement transition logic here (e.g., fade effects)
    return this.currentScene;
  }

Unloading Scenes

When we're done with a scene, we can unload it from memory using the unloadScene function. This helps to free up resources and keep our game running smoothly.

  unloadScene(sceneName: string) {
    // Make sure the scene exists.
    if (!this.scenes[sceneName]) {
      return;
    }

    // Detach resources before disposal.
    this.scenes[sceneName].detachControl();
    // Dispose of other resources as needed

    this.scenes[sceneName].dispose();
    delete this.scenes[sceneName];
  }

Processing Scene Code

The loadSceneCode function takes the raw scene data, processes it, and dynamically imports and instantiates the necessary components and systems.

  async loadSceneCode(entityManager: EntityManager, data: any) {
    // Process entities and components
    const entities: any = {};
    const componentTypes = new Set();
    const systems = [];
    for (const entityName in data.entities) {
      const entityData = data.entities[entityName];
      if (entityData)
        entities[entityName] = entityManager.createEntity(entityName);

      for (const componentType in entityData.components) {
        const componentData = entityData.components[componentType];

        // Dynamically import and create component
        const componentClass = await this.importComponent(componentType);
        if (componentClass) {
          entities[entityName].addComponent(new componentClass(componentData));
          componentTypes.add(componentType);
        } else {
          console.error(`Failed to load component: ${componentType}`);
        }
      }
    }

    // Derive required systems based on component types
    const requiredSystems = this.deriveRequiredSystems(componentTypes);

    // Import and create required systems
    for (const systemName of requiredSystems) {
      const systemModule = await import(`../systems/${systemName}System.ts`);
      const systemClass = systemModule[systemName + "System"];
      const system = new systemClass(entityManager);
      systems.push(system);
    }

    return { entities, componentTypes, systems }; // Return processed scene data
  }

Importing Components

The importComponent function handles the dynamic import of component classes based on their names.

  async importComponent(componentType) {
    try {
      const module = await import(`../components/${componentType}Component.ts`);
      return module[componentType + `Component`]; // Assuming components are exported as default
    } catch (error) {
      console.error(`Failed to import component: ${componentType}`, error);
      return null;
    }
  }

Deriving Required Systems

Once we have loaded the components for our scene, we need to figure out which systems are required to manage those components. The deriveRequiredSystems function takes care of this.

  deriveRequiredSystems(componentTypes) {
    const requiredSystems = new Set();
    for (const componentType of componentTypes) {
      const systemName = componentType.replace("Component", "System");
      requiredSystems.add(systemName);
    }
    return Array.from(requiredSystems);
  }

Updating Systems

Finally, the updateSystems function iterates through all loaded systems and calls their update methods, allowing them to perform their logic on the entities in the scene.

  updateSystems(deltaTime) {
    if (!this.sceneCode.systems) return;
    this.sceneCode.systems.forEach((system) => {
      if (system.update) system.update(deltaTime);
    });
  }

In Sum

Now, you have a system that can handle scene loading, switching, and unloading, and dynamically load the necessary components and systems for each scene. Now what happens when you create a new scene file, say, scene2.json and change the data. We have a new game, from the same code. And that's what's next.