logo

Babylon.js Market

By Lawrence

7 minutes

Framework Base Classes

In this article we're going to quickly write the base classes for our ECS framework. Very simple classes that set up our interface for all the game code that we're going to write.

New Files

Let's organize our ECS-related code within the src directory. Create a new folder named ECS inside src/lib.

In the terminal, cd into your project-folder and start making some files with the following commands.

shell
mkdir -p src/lib/ECS
touch src/lib/ECS/Entity.ts
touch src/lib/ECS/Component.ts
touch src/lib/ECS/System.ts
touch src/lib/ECS/World.ts
touch src/lib/ECS/index.ts

This will give you the following directory structure within your src folder:

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

index.ts will be the root so we can import each class in a slightly cleaner way. It's simple and looks like this.

lib/ECS/index.ts
import { Entity } from "./Entity";
import { World } from "./World";
import { Component } from "./Component";
import { System } from "./System";

export { Entity, World, Component, System };

Now, let's dive into the code for each base class.

Entities

Entity: a thing with distinct and independent existence

Entities are Nouns. Be it a Player an Enemy, the Ground they walk on, the Bullets they fire.

Our base Entity class will provide the fundamental contract for all entities in our game world. It will manage a collection of Components. It will be the only thing that can manage it's own components.

Open src/lib/ECS/Entity.ts and paste the following code:

src/lib/ECS/Entity.ts
import { Mesh, TransformNode } from "@babylonjs/core";
import { Component } from "./Component";

// Entity.ts
export class Entity extends Mesh {
  public name: string;
  private _components: Map<string, Component>;

  constructor(name: string) {
    super(name);
    this._components = new Map();
    this.name = name;
  }

  // Check if the entity has a specific component
  hasComponent(componentClass: any): boolean {
    return this._components.has(componentClass.name);
  }

  // Add a component to this entity
  addComponent(component: Component): Entity {
    component.entity = this;
    this._components.set(component.constructor.name, component);
    return this;
  }

  // Remove a component from this entity by its class
  removeComponent(componentClass: Component): Entity {
    this._components.delete(componentClass.constructor.name);
    return this;
  }

  // Get a component by its class
  getComponent(componentClass: Component): Component {
    try {
      const t = this._components.get(componentClass.name) as Component;
      if (!t) throw `${componentClass.name} Not Found for ${this.name}`;
      return t;
    } catch (e) {
      throw e;
    }
  }

  toJSON() {
    const entity = {name: this.name }
    const entity.components = this._components.forEach(component => {
      return component.toJSON();
    });
    return JSON.stringify(entity);
  }
}

The add/remove/get and hasComponent methods should be seen as simple wrappers to the native Javascript Map class methods. They can be used to manage which components an Entity has, creating distinct entities from their components.

Why use wrapper methods?

You might be wondering why we'd have such small functions just to wrap a regular ol javascript Map. Can't we just .get and .set the Map directly. The reason is we're setting up our contract with every other entity. This is how we do it. We're only one way to do it. The components Map is marked private for that reason.

Should Entity extend Mesh?

You may have noticed that the base Entity class itself extends the Mesh class. This is a design choice I came to after much trial and error. It's certainly possible it could be a TransformNode or not extend anything and rely on Components. But I found that regardless of what each Component does, they generally need a parent node in the system. Something that you can actually parent other Meshes. The only 2 classes that offer parenting are Mesh and TransformNode. Mesh also provides a few other methods that are quite useful, so I use that. As we'll see in the Assets article later

Use

example
const position = new Vector3(0, 0, 0);
const movementComponent = new MovementComponent({ position });
const player = new Entity("Player");
player.addComponent(movementComponent);
player.log();

The Component

Components in our framework are simply javascript objects which store specific data about one aspect of a specific Entity.

They are the single source of truth for that one piece of information.

For example the Component could store the entity's Position and Velocity. Compnents should hold the data they might need to access most often and basically "stick to doing one thing well." In this case Postition and Velocity sould like part of a MovementComponent.

The component objects are meant to be easily added and removed during gameplay. They can also be enabled or disabled as a way to add and remove without losing the data the component is tracking. A component should only know and manipulate it's own entity. It can't access other entities.

We leaving more shared game information to the World Entity or the individual systems.

Our base Component class will be very simple, providing a common foundation for all our game components.

Open src/lib/ECS/Component.ts and paste the following code:

src/lib/ECS/Component.ts
import { Entity } from "./Entity";

export class Component {
  public enabled: boolean = true; // Is the component active?
  public entity: Entity | null = null; // Convinient reference to the entity this component belongs to.

  constructor(data: any = {}) {
    // Optional data argument
    this.enabled = data.enabled ?? true; // Default to true if not specified

    // initialize other data from the data argument.
  }
}

More Complex Data

In addition to the input data. Components are a great place to hold other game objects related to an entity but not defined in the data files, which can be referenced later that don't neccessarily have to be the simple serializeable data.

These objects aren't serialized to any JSON file, but recreated for each entity on load.

For instance, if we needed a new instance of a StateMachine class we're using, for each Player, we could save it in the Component class as another property.

example
import StateMachine from "../class-defined-somewhere";

const MovementComponent extends Component {
  private state: StateMachine
  constructor(data: any) {
    super(data)    // 1.
    this.state = new StateMachine(data)  // 2.
  }
}

In this simplified example:

  1. We're calling super which will take the data object, which may contain a startingPosition vector and save the data it to the object automatically, because the super() function runs the constructor() function in the parent class.

  2. We're adding a custom object that can have it's own internal code to the component because when we access this component later we want to be able to reference the state machine through .state.

We'll get more into Components and StateMachines later.

The System Class

Systems are singleton classes that process entities containing their matching component. I prefer to keep both System and Component classes in the same file as they should be kept fairly short and sweet, being the glue to other game classes, and are reliant on each others existance.

In other words, a System will never process and Entity that doesn't contain it's own Component.

src/lib/ECS/System.ts
import { Scene } from "@babylonjs/core";

import { Entity } from "./Entity";
import { World } from "./World";

export type ComponentClass = Component => any;

export abstract class System {
  protected entities: Entity[] = [];
  protected componentClasses: ComponentClass[];
  protected scene: Scene;
  public world: World;
  public isPauseable: boolean = false;

  constructor(world: World, componentClasses: ComponentClass[]) {
    this.world = world;
    this.scene = world.currentScene;
    this.componentClasses = componentClasses;
  }

  // Updates each entity this system is concerned with
  update(deltaTime: number): void {
    if (this.isPauseable && this.world.isPaused) return;
    const entities = this.world.entitiesWith(this.componentClasses);
    for (const entity of entities) {
      if (this.processEntity) this.processEntity(entity, deltaTime);
    }
  }

  loadEntities(): void {
    if (!this.loadEntity) return;
    const entities = this.world.entitiesWith(this.componentClasses);
    for (const entity of entities) {
      this.loadEntity(entity);
    }
  }

  // To be implemented in subclasses.
  public abstract load(): void;
  public abstract loadEntity(entity: Entity): void;
  public abstract processEntity(entity: Entity, deltaTime: number): void;
}

This System base class will be extended by other classes in your games. In Object-Oriented Programming, having a base class to extend is called a Contract. It attempts to standardize the way the program will be built though these parent classes being extended once.

This allows us to create a unified architecture to make sure Systems and Components work together, know how to get information from other components and use a naming scheme that's consistent throughout.

The public abstract functions are meant to be overwritten by a sub-class implementing the specfics. Even though the system classes are instanced and created like the entities and component classes. There is only ever one instance of each system in the game. In this way saving more global information to the System acts like a global singleton.

In Sum

We've gone over a lot of code, all to organize the Game around an ECS coding pattern. It should becoming clear that in ECS we use OOP principles a lot. Now that we have the base classes written. (Small, aren't they?) We can work on loading them in a World class.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search