Introducing ECSY: an Entity Component System framework

WebXR, A-Frame, three.js

Today we are introducing ECSY (Pronounced “eck-see”): a new -highly experimental- Entity Component System framework for Javascript.

After working on many interactive graphics projects for the web in the last few years we were trying to identify the common issues when developing something bigger than a simple example. Based on our findings we discussed what an ideal framework would need:

  • Component-based: Help to structure and reuse code across multiple projects.
  • Predictable: Avoids random events or callbacks interrupting the main flow, which would make it hard to debug or trace what is going on in the application.
  • Good performance: Most web graphics applications are CPU bound, so we should focus on performance much more.
  • Simple API: The core API should be simple, making the framework easier to understand, optimize and contribute to; but also allow building more complex layers on top of it if needed.
  • Graphics engine agnostic: It should not be tied to any specific graphics engine or framework.

These requirements are high-level features that are not usually provided by graphics engines like three.js or babylon.js. On the other hand, A-Frame provides a nice component-based architecture, which is really handy when developing bigger projects, but it lacks the rest of the previously mentioned features. For example:

  • Performance: Dealing with the DOM implies overhead. Although we have been building A-Frame applications with good performance, this could be done by breaking the API contract, for example by accessing the values of the components directly instead of using setAttribute/getAttribute. This can lead to some unwanted side effects, such as incompatibility between components and a lack of reactive behavior.
  • Predictable: Dealing with asynchrony because of the DOM life cycle or the events’ callbacks when modifying attributes makes the code really hard to debug and to trace.
  • Graphics engine agnostic: Currently A-Frame and its components are so strongly tied to Three.js that it makes no sense to change it to any other engine.

After analyzing these points, gathering our experience with three.js and A-Frame, and looking at the state of the art on game engines like Unity, we decided to work on building this new framework using a pure Entity Component System architecture. The difference between a pure ECS like Unity DOTS, entt, or Entitas, and a more object oriented approach, such as Unity’s MonoBehaviour or A-Frame's Components, is that in the latter the components and systems both have logic and data, while with a pure ECS approach components just have data (without logic) and the logic resides in the systems.

Focusing on building a simple core for this new framework helps iterate faster when developing new applications and lets us implement new features on top of it as needed. It also allows us to use it with existing libraries as three.js, Babylon.js, Phaser, PixiJS, interacting directly with the DOM, Canvas or WebGL APIs, or prototype around new APIs as WebGPU, WebAssembly or WebWorkers.

Stacks Technology stack examples

Architecture

We decided to use a data-oriented architecture as we noticed that having data and logic separated helps us better think about the structure of our applications. This also allows us to work internally on optimizations, for example how to store and access this data or how to get the advantage of parallelism for the logic.

The terms you must know in order to work with our framework are mostly the same as any other ECS:

  • Entities: Empty objects with unique IDs that can have multiple components attached to it.
  • Components: Different facets of an entity. ex: geometry, physics, hit points. Components only store data.
  • Systems: Where the logic is. They do the actual work by processing entities and modifying their components. They are data processors, basically.
  • Queries: Used by systems to determine which entities they are interested in, based on the components the entities own.
  • World: A container for entities, components, systems, and queries.

ECSY-Architecture ECSY Architecture

Example

So far all the information has been quite abstract, so let’s dig into a simple example to see how the API feels.

The example will consist of boxes and circles moving around the screen, nothing fancy but enough to understand how the API works.

We will start by defining components that will be attached to the entities in our application:

  • Position: The position of the entity on the screen.
  • Velocity: The speed and direction in which the entity moves.
  • Shape: The type of shape the entity has: circle or box. Now we will define the systems that will hold the logic in our application:
  • MovableSystem: It will look for entities that have speed and position and it will update their position component.
  • RendererSystem: It will paint the shapes at their current position.

Circles and balls example Circles and balls example design

Below is the code for the example described, some parts have been omitted to abbreviate (Check the full source code on Github or Glitch)


We start by defining the components we will be using:

// Velocity component
class Velocity {
constructor() {
this.x = this.y = 0;
}
}

// Position component
class Position {
constructor() {
this.x = this.y = 0;
}
}

// Shape component
class Shape {
constructor() {
this.primitive = 'box';
}
}

// Renderable component
class Renderable extends TagComponent {}

Now we implement the two systems our example will use:

// MovableSystem
class MovableSystem extends System {
// This method will get called on every frame by default
execute(delta, time) {
// Iterate through all the entities on the query
this.queries.moving.results.forEach(entity => {
var velocity = entity.getComponent(Velocity);
var position = entity.getMutableComponent(Position);
position.x += velocity.x * delta;
position.y += velocity.y * delta;

if (position.x > canvasWidth + SHAPE_HALF_SIZE) position.x = - SHAPE_HALF_SIZE;
if (position.x < - SHAPE_HALF_SIZE) position.x = canvasWidth + SHAPE_HALF_SIZE;
if (position.y > canvasHeight + SHAPE_HALF_SIZE) position.y = - SHAPE_HALF_SIZE;
if (position.y < - SHAPE_HALF_SIZE) position.y = canvasHeight + SHAPE_HALF_SIZE;
});
}
}

// Define a query of entities that have "Velocity" and "Position" components
MovableSystem.queries = {
moving: {
components: [Velocity, Position]
}
}

// RendererSystem
class RendererSystem extends System {
// This method will get called on every frame by default
execute(delta, time) {

ctx.globalAlpha = 1;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvasWidth, canvasHeight);

// Iterate through all the entities on the query
this.queries.renderables.results.forEach(entity => {
var shape = entity.getComponent(Shape);
var position = entity.getComponent(Position);
if (shape.primitive === 'box') {
this.drawBox(position);
} else {
this.drawCircle(position);
}
});
}
// drawCircle and drawCircle hidden for simplification
}

// Define a query of entities that have "Renderable" and "Shape" components
RendererSystem.queries = {
renderables: { components: [Renderable, Shape] }
}

We create a world and register the systems that it will use:

var world = new World();
world
.registerSystem(MovableSystem)
.registerSystem(RendererSystem);

We create some entities with random position, speed, and shape.

for (let i = 0; i < NUM_ELEMENTS; i++) {
world
.createEntity()
.addComponent(Velocity, getRandomVelocity())
.addComponent(Shape, getRandomShape())
.addComponent(Position, getRandomPosition())
.addComponent(Renderable)
}

Finally, we just have to update it on each frame:

function run() {
// Compute delta and elapsed time
var time = performance.now();
var delta = time - lastTime;

// Run all the systems
world.execute(delta, time);

lastTime = time;
requestAnimationFrame(run);
}

var lastTime = performance.now();
run();

Features

The main features that the framework currently has are:

  • Engine/framework agnostic: You can use ECSY directly with whichever 2D or 3D engine you are already used to. We have provided some simple examples for Babylon.js, three.js, and 2D canvas. To make things even easier, we plan to release a set of bindings and helper components for the most commonly used engines, starting with three.js.
  • Focused on providing a simple, yet efficient API: We want to make sure to keep the API surface as small as possible, so that the core remains simple and is easy to maintain and optimize. More advanced features can be layered on top, rather than being baked into the core.
  • Designed to avoid garbage collection as much as possible: It will try to use pools for entities and components whenever possible, so objects won’t be allocated when adding new entities or components to the world.
  • Systems, entities, and components are scoped in a “world” instance: It means that we don’t register the components or systems on the global scope, allowing you to have multiple worlds or apps running on the same page without interferences between them.
  • Multiple queries per system: You can define as many queries per system as you want.
  • Reactive support:
    • Systems can react to changes in the entities and components
    • Systems can get mutable or immutable references to components on an entity.
  • Predictable:
    • Systems will always run in the order they were registered or based on a priority attribute.
    • Reactive events won’t generate “random” callbacks when emitted. Instead they will be queued and processed in order, when the listener systems are executed.
  • Modern Javascript: ES6, classes, modules

What’s next?

This project is still in its early days so you can expect a lot of changes with the API and many new features to arrive in the upcoming weeks. Some of the ideas we would like to work on are:

  • Syntactic sugar: As the API is still evolving we have not focused on adding a lot of syntactic sugar, so currently there are places where the code is very verbose.
  • Developer tools: In the coming weeks we plan to release a developer tools extension to visualize the status of the ECS on your application and help you debug and understand its status.
  • ecsy-three: As discussed previously ecsy is engine-agnostic, but we will be working on providing bindings for commonly used engines starting with three.js.
  • Declarative layer: Based on our experience working with A-Frame, we understand the value of having a declarative layer so we would like to experiment with that on ECSY too.
  • More examples: Keep using a diverse range of underlying APIs, such as canvas, WebGL and engines like three.js, babylon.js , etc.
  • Performance: We have not really dug into optimizations on the core and we plan to look into it in the upcoming weeks and we will be publishing some benchmarking and results. The main goal of this initial release was to have an API we like so we could then focus on the core in order to make it run as fast as possible. You may notice that ECSY is not focused on data-locality or memory layout as much as many native ECS engines. This has not been a priority in ECSY because in Javascript we have far less control over the way things are laid out in memory and how our code gets executed on the CPU. We get far bigger wins by focusing on preventing unnecessary garbage collection and optimizing for JIT. This story will change quite a bit with WASM, so it is certainly something we want to explore for ECSY in the future.
  • WASM: We want to try to implement parts of the core or some systems on WASM to take advantage of strict memory layout and parallelism by using WASM threads and SharedArrayBuffers.
  • WebWorkers: We will be working on examples showing how you can move systems to a worker to run them in parallel.

Please feel free to use Github to follow the development, request new features or file issues on bugs you find, our discourse forum to discuss how to use ecsy on your projects, and ecsy.io for more examples and documentation.