The Entity-Component-System pattern

The Entity-Component-System (ECS) is an architectural pattern that is particularly suited to video game development. In this article we'll explain the benefits of using ECS and demonstrate a basic implementation using several systems and components.

In classic Object-oriented programming (OOP), a computer program is designed around objects, which contain all the data and logic needed for their operation. These objects are usually defined through classes, which serve as templates for creating many objects of the same type. To enable code reuse, classes may inherit from each other, taking on all the attributes and functionality of their parent class. However, because game classes can have very complex behavior, they may need to inherit from many parent classes at different stages, making it difficult to reason about the inner workings of classes or extend them without risking breaking functionality somewhere else. In addition, multiple inheritance is very complex, making lateral code reuse difficult.

In keeping with the principle of favoring composition over inheritance, ECS takes a completely different approach where instead of objects having complete control of their internal behavior, the elements of a game are instead organized into entities. Every "thing" in our game is an entity, and by default entities don't have any behavior, but are just a collection of components. Components are pieces of data that can be attached to an entity. Attaching a component to an entity will give the entity whatever functionality the component is responsible for. Components are matched to particular systems, and each system controls the logic and behavior for a specific aspect of the game.

Let's demonstrate the ECS pattern by refactoring the bouncing ball demo we had in a previous article.

We'll begin by creating our base Entity, Component, and System classes. All other components and systems will inherit from these base classes. We'll start by creating the ecs directory. In this directory, let's also create the entity.js and put the following code in it:

export default class Entity {
    constructor() {
        this.id = crypto.randomUUID();
        this.components = [];
    }

    attachComponents(...components) {
        this.components = [...this.components, ...components];
    }

    deleteComponents() {
        for(const component of this.components) {
            component.delete();
        }
        this.components = [];
    }
}

From the code, we can see than an entity is nothing but a collection of components. We can associate a component with an entity by calling the entity's attachComponents() method. Unlike in Object-oriented Programming where we can easily delete objects, deleting entities is a bit harder since its components are loosely coupled and have to be deleted individually. For this reason the Component class supports the delete() method, and the Entity class has the deleteComponents() method which will iterate across all attached components and call their delete() method one by one. Finally, an entity also has an id, allowing us to uniquely identify any instance of the Entity class from another.

In the ecs directory, let's also create the component.js file and put the following code in it:

export default class Component {
    constructor() {
        this.id = crypto.randomUUID();
        this.isDeleted = false;
    }

    delete() {
        this.isDeleted = true;
    }
}

One important thing to note for the Component class is that calling its delete() method only sets the isDeleted = true, and it's the responsibility of the system that created the component to also safely delete it at the right time. Generally we want to delete components at the end of a frame once everything else has been processed. Like the Entity, each instance of the Component class also has an id, and while we're not using the id at the moment, it will be very useful in later chapters.

Finally, let's also also create the system.js file in the ecs directory, and put the following code in it:

export default class System {
    constructor() {
        this.components = [];
    }

    update() {
        // nothing here for now
    }

    deleteStaleComponents() {
        this.components = this.components.filter(x => !x.isDeleted);
    }
}

By itself the System class does nothing, but do note the components variable, which hints that the system that creates a component also manages it. The update() method is also empty, but all other systems that inherit from this base class will override this method to implement the core functionality of the system. It's important to note that this method will be called by the game loop every frame, and this is how we'll drive the behavior of the entire game engine. Finally, the deleteStaleComponents removes from the list all components that have been set as deleted. This method will be called at the end of each frame.

Now that we've created our base ECS classes, it's time to create a few more that will implement the rules of the game. We'll first need to identify the behaviors that were manifested by the bouncing ball. The ball had three distinct behaviors:

We'll need a system and its corresponding component for each of these behaviors.

The first component we'll create is the BodyComponent. Let's create a physics directory, create a bodyComponent.js file in this directory and put the following code in it:

import Component from '../ecs/component';
import Vec2 from '../vec2';

export default class BodyComponent extends Component {
    constructor(posX, posY) {
        super();
        this.position = new Vec2(posX, posY);
        this.velocity = new Vec2(200, 0);
        this.acceleration = new Vec2(0, 500);
        this.radius = 50;
    }
}

Like we mentioned earlier, components hold data for some aspect of our entities, in this case data about the physical aspects of the entity. The acceleration and velocity have been hardcoded to match the behavior of the previous article, but can be changed to whatever we like.

The PhysicsSystem will be responsible for moving the bodies every frame. Let's create a physicsSystem.js file, put it in the physics directory, and add the following code in it:

import System from '../ecs/system';
import BodyComponent from './bodyComponent';

export default class PhysicsSystem extends System {
    constructor() {
        super();
    }

    update(delta) {
        for(const component of this.components) {
            component.velocity = component.velocity.add(component.acceleration.scale(delta));
            component.position = component.position.add(component.velocity.scale(delta));
        }
    }

    createBodyComponent(posX, posY) {
        const component = new BodyComponent(posX, posY);
        this.components.push(component);
        return component;
    }
}

As you can see, the PhysicsSystem is responsible for creating instances of BodyComponent classes with the createBodyComponent method. The system needs to store components locally so they can be iterated in the update() method. We'll use this pattern in all of our systems and let the system handle the lifecycle of the components its responsible for.

Similarly, we'll need a system and corresponding component for the feature that draws shapes on the screen. Let's create a graphics directory, a bodyGraphicsComponent.js file, and let's put the following code in it:

import Component from "../ecs/component";

export default class BodyGraphicsComponent extends Component {
    constructor(bodyComponent) {
        super();
        this.bodyComponent = bodyComponent;
        this.setRandomColor();
    }

    setRandomColor() {
        this.color = randomColor();
    }
}

function randomColor() {
    let red = Math.floor(Math.random()*256);
    let green = Math.floor(Math.random()*256);
    let blue = Math.floor(Math.random()*256);
    return (red << 16) + (green << 8) + blue;
}

Like the BodyComponent we created earlier, the BodyGraphicsComponent contains the data it needs to draw the ball on the screen. However, in addition to the color we also need a reference to the BodyComponent instance that we're trying to draw. This is because the dimensions and position of the body are stored directly in the BodyComponent instance, and as the body moves we'll need an updated position to know where to draw the ball on the screen.

There are many ways that entities and components can communicate with each other, and in a future article we'll examine a popular approach (the Event Bus) in more detail, but when we have the opportunity to code the dependency between components directly, we should do it, since the easiest and simplest way that components can communicate is for one to hold a reference to the other and directly access its values.

The GraphicsSystem goes together with the BodyGraphicsComponent, therefore we need to create the graphicsSystem.js file and put it in the physics directory. Let's also place the following code in that file:

import * as PIXI from "pixi.js";
import System from "../ecs/system";
import BodyGraphicsComponent from "./bodyGraphicsComponent";
import config from '../config';

export default class GraphicsSystem extends System {
    constructor() {
        super();
        this.parentElement = document.getElementById(config.rootElementId);
        this.width = config.width;
        this.height = config.height;
        this.app = new PIXI.Application({width: this.width, height: this.height});
        this.parentElement.appendChild(this.app.view);
        this.graphics = new PIXI.Graphics();
        this.app.stage.addChild(this.graphics);
    }

    update() {
        this.graphics.clear();
        for(const component of this.components) {
            this.graphics.beginFill(component.color);
            this.graphics.drawCircle(component.bodyComponent.position.x, component.bodyComponent.position.y, component.bodyComponent.radius);
        }
    }

    createGraphicsComponent(bodyComponent) {
        let graphicsComponent = new BodyGraphicsComponent(bodyComponent);
        this.components.push(graphicsComponent);
        return graphicsComponent;
    }
}

The GraphicsSystem is responsible for creating BodyGraphicsComponent instances but also for setting up the PIXI instance and directly drawing the graphics on the screen. The system itself holds many variables that it needs to do its job, such as the width of the game, the root HTML element that carries the game, and a few more. It's very common for system to hold their own state that is independent from the components.

Note how we're importing data from a file called config.js that we have not defined yet. This file is useful for storing global settings. Let's create this file and put the following code there:

const config = {
    width: 640,
    height: 360,
    rootElementId: 'pixi-root'
}

export default config;

The final feature we'll implement is the screen edge bounce. Like with the other features, we'll need a system and a component to make it work. Let's start with the component first: create a screenEdgeBounce directory, create a screenEdgeBounceComponent.js file in it, and put the following code in it:

import Component from '../ecs/component';

export default class ScreenEdgeBounceComponent extends Component {
    constructor(bodyComponent, graphicsComponent) {
        super();
        this.bodyComponent = bodyComponent;
        this.graphicsComponent = graphicsComponent;
    }
}

The ScreenEdgeBounceComponent does not have any data of its own, but just hold references to the other two components we defined earlier. These components are necessary because we need to know the position of the ball to know when it reaches the edges of the screen, and we also need the BodyGraphicsComponent instance so we can change the color of the ball when it touches the edges.

You might be wondering if we actually need the ScreenEdgeBounceComponent and if we could allow multiple systems to access the same component directly. This is generally not advisable since we might want to apply the system rules to only a small number of child components, for example we might not want all BodyComponent instances to bounce around the walls, but only those that we wrap with a ScreenEdgeBounceComponent. A component should be used to add a single behavior to an entity, and since we're dealing with multiple behaviors, it's best to use one component for each behavior, even if the components use the same underlying data.

Finally, we can create the ScreenEdgeBounceSystem. Let's create the screenEdgeBounceSystem.js file in the screenEdgeBounce directory and put the following code there:

import System from '../ecs/system';
import ScreenEdgeBounceComponent from './screenEdgeBounceComponent';
import Vec2 from '../vec2';
import config from '../config';

export default class ScreenEdgeBounceSystem extends System {
    constructor() {
        super();
        this.width = config.width;
        this.height = config.height;
    }

    update() {
        
        for(const component of this.components) {
            const bodyComponent = component.bodyComponent;
            const graphicsComponent = component.graphicsComponent;
            if(bodyComponent.position.x + bodyComponent.radius >= this.width) {
                let diff = bodyComponent.position.x + bodyComponent.radius - this.width;
                bodyComponent.position = new Vec2(bodyComponent.position.x - 2 * diff, bodyComponent.position.y);
                bodyComponent.velocity = new Vec2(-bodyComponent.velocity.x, bodyComponent.velocity.y);
                graphicsComponent.setRandomColor();
            }
            if(bodyComponent.position.x - bodyComponent.radius <= 0) {
                let diff = bodyComponent.position.x - bodyComponent.radius;
                bodyComponent.position = new Vec2(bodyComponent.position.x - 2 * diff, bodyComponent.position.y);
                bodyComponent.velocity = new Vec2(-bodyComponent.velocity.x, bodyComponent.velocity.y);
                graphicsComponent.setRandomColor();
            }
            if(bodyComponent.position.y + bodyComponent.radius > this.height) {
                let diff = bodyComponent.position.y + bodyComponent.radius - this.height;
                bodyComponent.position = new Vec2(bodyComponent.position.x, bodyComponent.position.y - 2 * diff);
                bodyComponent.velocity = new Vec2(bodyComponent.velocity.x, -bodyComponent.velocity.y);
                graphicsComponent.setRandomColor();
            }
        }
    }

    createScreenEdgeBounceComponent(bodyComponent, graphicsComponent) {
        const component = new ScreenEdgeBounceComponent(bodyComponent, graphicsComponent);
        this.components.push(component);
        return component;
    }
}

The functionality of this system is the same as last time, if the ball touches the left, right, or bottom edge of the screen, reverse its direction of movement, move it away from the edge, and change the color of the ball. Note how we're using the config object we declared earlier.

Now that we're done with the components and systems, it's time to try them out. In the index.js file, let's put the following code:

import * as MainLoop from 'mainloop.js';
import PhysicsSystem from './physics/physicsSystem';
import ScreenEdgeBounceSystem from './screenEdgeBounce/screenEdgeBounceSystem';
import GraphicsSystem from './graphics/graphicsSystem';
import Entity from './ecs/entity';

let physicsSystem = new PhysicsSystem();
let screenEdgeBounceSystem = new ScreenEdgeBounceSystem();
let graphicsSystem = new GraphicsSystem();

createBouncingBall(50, 50);
createBouncingBall(200, 70);
createBouncingBall(350, 90);
createBouncingBall(500, 110);

MainLoop.setUpdate((delta) => {
    const deltaInSecs = delta / 1000;
    physicsSystem.update(deltaInSecs);
    screenEdgeBounceSystem.update();
    
    physicsSystem.deleteStaleComponents();
    screenEdgeBounceSystem.deleteStaleComponents();
}).setDraw(() => {
    graphicsSystem.update();
    graphicsSystem.deleteStaleComponents();
});

MainLoop.start();

function createBouncingBall(posX, posY) {
    let entity = new Entity();
    let bodyComponent = physicsSystem.createBodyComponent(posX, posY);
    let graphicsComponent = graphicsSystem.createGraphicsComponent(bodyComponent);
    let screenEdgeBounceComponent = screenEdgeBounceSystem.createScreenEdgeBounceComponent(bodyComponent, graphicsComponent);
    entity.attachComponents(bodyComponent, graphicsComponent, screenEdgeBounceComponent);
}

Like we mentioned earlier, entities are just a collection of components. When we create a ball and place it in the world, we create an instance of the Entity class and any other components it might need and then we create and attach any necessary components. Once the components have been attached, the systems take care of applying the game rules. On every frame we call the update() method of every system, and this is how the world gets updated over time.

At the end of each frame, we call each system's deleteStaleComponents() method, which will remove from the game any component that we've deleted. In this example there are no such examples, but we'll leave this for the future when we have more complex behavior.

It's important to note that the order in which we update the systems matters for our game's rules. In our case we updated the physics first, then we checked if the ball touched the edges. This allows us to immediately react if the ball is touching the edges. As we add more systems for a more complex game, it will be important to make a design decision in which order will the rules of the game will be applied.

One other detail to note is how the GraphicsSystem is updated separately from the other systems. This is a feature provided by the MainLoop.js library which is normally locked at a fixed frame rate (by default 60 frames per second). By updating the GraphicsSystem during setDraw() instead of setUpdate(), we can get potentially smoother graphics by refreshing the screen as fast as we can.

We finally finished the implementation of the ECS pattern. We'll be using this pattern extensively in the future, in general any new feature that we'll add will be implemented by adding new systems and their corresponding components.

Next: Input and controls