logo

Babylon.js Market

By Lawrence

7 minutes

Building a Game World

In any game world, it's helpful to have a World class, which orchistrates the Entities, Components and Systems in the game. Sometimes coders name this the EntityManager, but I'd rather not use the OOP Manager nomenclature.

You should already have the following directory structure within your src folder that we setup last article:

stdout
src/
├── lib/
│   └─ ECS/
│      ├── index.ts
│      ├── Component.ts
│      ├── Entity.ts
│      └── System.ts
│      └── World.ts

The World Class

Now that we've setup ECS base classes we can write the World class. What does a World do in the ECS pattern? Basically we're using it as the holder of all the entities and everything else associated with the loaded data.

src/lib/ECS/World.ts
import { AbstractEngine, Engine, Scene } from "@babylonjs/core";
import { System } from "./System";
import { Entity } from "./Entity";
import { Component } from "./Component";

const BASE_COMPONENT_DIR = "../../Components/";

export class World {
  canvas: HTMLCanvasElement;
  entities: Map<String, Entity>;
  engine: AbstractEngine;
  currentScene: Scene;
  originalSceneData: any;
  sceneCode: any | undefined;
  isPaused: boolean = false;
  worldEntity: Entity | undefined;
  worldName: string = "World";

  private componentCache: Map<string, { component: Component; system: any }> =
    new Map();

  constructor(canvasId: string) {
    this.canvas = document.getElementById(canvasId) as HTMLCanvasElement;
    this.engine = new Engine(this.canvas, true);
    this.entities = new Map();
    this.currentScene = new Scene(this.engine);
  }
  ...

The World constructor takes the canvasId and takes it from there. In Babylon.js we need to create an Engine and give the canvas to it. Then we create a new Scene and pass it the engine.

The World Loads Everything

Here we defined a couple of functions that are imported into main.ts and run first thing the browser does.

src/lib/ECS/World.ts
  async loadSystems() {
    return this.sceneCode.systems.forEach((system: System) => {
      system.load && system.load();
      system.loadEntities && system.loadEntities();
    });
  }

  async start() {
    // 1. Load everything first
    await this.loadSystems();

    // 2. Then start the game loop.
    this.engine.runRenderLoop(() => {
      const scene = this.currentScene;
      if (!scene || !scene.isReady()) return;
      const deltaTime = this.engine.getDeltaTime();
      this.updateSystems(deltaTime);
      if (scene.activeCamera) scene.render();
    });

    // 6. Resize the game when the window resizes.
    window.addEventListener("resize", () => {
      this.engine.resize();
    });
  }

Back to main.js

In the first article of this series we setup the main.ts file. We're now ready to add the world starting to it.

src/main.ts
import { World } from "./lib/ECS";

document.addEventListener("DOMContentLoaded", async () => {
  const params = new URLSearchParams(location.search);
  const gameName = params.get("game") || "FirstGame";
  const level = params.get("scene") || params.get("level") || "0";
  const canvasId = "renderCanvas";

  // This is the only part you need to add
  const world = new World(canvasId);
  await world.loadSceneData(level, gameName);
  world.start();
});

Loading the ECS Scene Definition from JSON

We'll load scene data from the JSON files we made in A Previous Article. Why the JSON format? Because it's human-readable (mostly), and it's easier to work with by non-technical people while in development. But also because it enforces data only. Functions can't be defined in JSON, so we'll be using it as a way to keep the code and data seperate throughout the process.

Why am I fetching the scene data?

Now, of course, one doesn't have to import data thought a fetch. That's just one way. This game could have a hard coded reference to the data/FirstGame/scenes/0.ts that just is the data bundled into the game code.

What we're trying to build. We don't just want the one scene. We want unlimited scenes that we can jump between to make the levels and other distinct moments of the game.

So Anyway, We fetch some data.

In src/lib/ECS/World.ts:

src/lib/ECS/World.ts
async loadSceneData(sceneName: string, gameName: string) {
  // 1.
  const scenePath = `/GameData/${gameName}/scenes/${sceneName}.json`;
  const response = await fetch(scenePath, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
  });
  const sceneData = await response.json();
  if (!sceneData) {
    throw "Data is not in JSON Format";
    return;
  }
  if (!sceneData.entities) {
    throw "No entities property in sceneData";
    return;
  }
  this.originalSceneData = sceneData;
  this.sceneCode = await this.loadSceneCode(sceneData);
  document.title = `${gameName} - ${sceneName}`;
}
  1. The path to a scene json file can be anywhere on the internet, so long as CORS isn't an issue. Here we're simply looking in the public/GameData folder for the gameName and sceneName.

We only need one System class for each unique Component in the scene. The System classes process all entities for that System at the same time. So the first thing we do is iterate over the entities listed in the data file and grab all of their components. The Set data type will make sure it's entries are unique for us.

Remember, one of our design constraints is, there's always Component and System pairs of the same name.

src/lib/ECS/World.ts
async loadSceneCode(data: any) {
  // Process entities and components
  const entities: any = {};
  const componentTypes = new Set<string>();
  const systems: System[] = [];
  const components: Map<string, Component> = new Map();

  const uniqueComponents = new Set<string>(
    Object.keys(data.entities).flatMap((entityName) =>
      Object.keys(data.entities[entityName].components),
    ),
  );

  // 1.
  await this.loadUniqueComponents(uniqueComponents);
  // 2.
  this.processEntitiesAndComponents(
    data,
    entities,
    componentTypes,
    components,
  );
  // 3.
  this.instantiateSystems(systems);

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

Load the Code

This is where we load components and system code dynamically based on the data from the .json file we just fetched.

In vite this is easy enough using import().

src/lib/ECS/World.ts
  private async loadUniqueComponents(uniqueComponents: Set<string>) {
    for (let componentType of uniqueComponents) {
      try {
        const { component, system } =
          await this.importComponentAndSystem(componentType);
        if (component && system) {
          this.componentCache.set(componentType, {
            component,
            system,
          });
        }
      } catch (error) {
        console.error(
          `Failed to import component/system: ${componentType}`,
          error,
        );
      }
    }
  }

  async importComponentAndSystem(componentType: string): any {
    try {
      const module = await import(
        /* @vite-ignore */ `${BASE_COMPONENT_DIR}${componentType}.ts`
      );
      const component = module[`${componentType}Component`] as Component;
      const system = module[`${`${componentType}System`}`] as System;
      return { component, system };
    } catch (error) {
      console.error(
        `Failed to import component/system: ${componentType}`,
        error,
      );
      return { component: null, system: null };
    }
  }

Process and Create Entities

The World class needs methods for adding and removing entities from the entities map. So let's add those quickly.

src/lib/ECS/World.ts
  createEntity(name: string) {
      const entity = new Entity(name);
      this.entities.set(entity.name, entity);
      return entity;
  }

  removeEntity(entity: Entity) {
      this.entities.delete(entity.name);
  }

Now comes the part where we loop over the data file and construct actual the entities in code. It can look a bit intemidating.

src/lib/ECS/World.ts
  private processEntitiesAndComponents(
    data: any,
    entities: any,
    componentTypes: Set<string>,
    components: Map<string, Component>,
  ) {
    //Process each entity and component
    for (const entityName in data.entities) {
      const entityData = data.entities[entityName];
      if (entityData) {
        entities[entityName] = this.createEntity(entityName);
        if (entityName == data.worldEntity)
          this.worldEntity = entities[entityName];
      }
      for (const componentType in entityData.components) {
        const componentData = entityData.components[componentType];

        // Use cached component and create new instance.
        const cachedComponent = this.componentCache.get(componentType);
        // debugger;
        if (cachedComponent && cachedComponent.component) {
          components.set(componentType, cachedComponent.component);
          const c = new (cachedComponent.component as any)(componentData);
          entities[entityName].addComponent(c);
          componentTypes.add(componentType);
        } else {
          console.error(`Failed to load component: ${componentType}`);
        }
      }
    }
  }

Create Only Systems We Need

Finally.

src/lib/ECS/World.ts
  private instantiateSystems(systems: System[]) {
    for (const systemName of this.componentCache) {
      const cachedComponent = this.componentCache.get(systemName[0]);
      if (cachedComponent && cachedComponent.system) {
        const system = new cachedComponent.system(this, [
          cachedComponent.component,
        ]);
        systems.push(system);
      } else {
        console.warn(`System not found in cache for: ${systemName}`); //Handle missing system appropriately
      }
    }
  }

Updating Systems every Frame

Now we need a way to update our systems. This is done inside the game loop. Remember this in the start method?

src/lib/ECS/World.ts
this.engine.runRenderLoop(() => {
  const scene = this.currentScene;
  if (!scene || !scene.isReady()) return;
  const deltaTime = this.engine.getDeltaTime();
  this.updateSystems(deltaTime);
  if (scene.activeCamera) scene.render();
});

Earlier in loadSceneCode We returned an object full of every system object and saved it to this.sceneCode. Now we can loop through each and if it has an update method, it runs it.

src/lib/ECS/World.ts
  updateSystems(deltaTime: number) {
      if (!this.sceneCode || !this.currentScene.isReady()) return;

      this.sceneCode.systems.forEach((system: System) => {
          if (system.update) system.update(deltaTime);
      });
  }

Helper Methods

Let's add some helper methods to the World Class to make our lives easier when grabbing entities from our world.

src/lib/ECS/World.ts
  // Find by Entity Name or an array of Names
  search(name: string[] | string): Entity | Entity[] | undefined {
      if (Array.isArray(name)) {
          return name
              .map((n) => {
                  return this.entities.get(n);
              })
              .filter((e) => !!e);
      }
      return this.entities.get(name);
  }

  // Find all with Component(s)
  entitiesWith(componentClasses: Component[]): Entity[] {
      const entities = this.entities.values();
      const e = Array.from(entities).filter((entity: Entity) =>
          componentClasses.every((comp) => entity.hasComponent(comp)),
      );
      const m = new Map();
      e.forEach((entity) => m.set(entity.name, entity));
      return e;
  }

In Sum

The World is a big place. In ECS a World class tends to add and remove Entities and Components. Another name for it might be an EntityManager but I'm not into managers too much. It's overused.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search