@ajeeb/ecs

Entity Component System

A simple entity component system inspired by EnTT.

Entities

An entity is just an opaque identifier managed by the ECS system. You can create them with create, and under the hood they are just JavaScript integers.

import { Ecs } from '@ajeeb/ecs'
const ecs = new Ecs()
const player = ecs.create()
const enemy = ecs.create()
console.log(player, enemy)
// 0 1

Entities are generational meaning the system will recycle old values whenever possible and encode each entity's generation in the number itself. This is visible when destroying entities with destroy.

ecs.destroy(enemy)
const enemy2 = ecs.create()
console.log(player, enemy, enemy2)
// 0 1 4194305

The exact encoding of generations is an implementation detail you should not depend on -- treat entities as opaque identifiers. You can check the validity of an entity with isValid.

console.log(ecs.isValid(player), ecs.isValid(enemy), ecs.isValid(enemy2))
// true false true

The entity stored in enemy is no longer valid because it was destroyed while the other entities remain valid. This is useful for situations where entity values get stored in different parts of your code and need to check if they've been destroyed.

Components

Entities are just identifiers and contain no data or logic. Data is associated with entities by assigning components. A component is a plain JavaScript object with a constructor that can be used to identify it. Any object that meets that definition can be used, but ES6 classes are ideal.

You assign a component to an entity with assign which has two syntaxes. For simple components you can use a combination of the constructor value and a plain data object.

class Position { x = 0; y = 0 }
ecs.assign(player, Position, { x: 100, y: 200 })
ecs.assign(enemy2, Position, { x: 50 })

Under the hood this calls the constructor with no arguments and uses Object.assign to populate fields. The data object is optional making this syntax convenient for empty components that act as "tags".

class Enemy {}
ecs.assign(enemy2, Enemy)

You can also assign full instances, allowing you to take advantage of custom constructors or factory functions.

class Speed { 
value = 0
constructor(mode) {
this.value = mode === 'fast' ? 10 : 1
}
}
ecs.assign(player, new Speed('fast'))

Each entity can only have one component of a given type.

You get components with get and check for their existence with has.

console.log(ecs.get(player, Position))
// Position { x: 100, y: 200 }

console.log(ecs.has(player, Position), ecs.has(player, Enemy))
// true false

Due to them being instances of classes you can write methods for your components if you like.

class Health {
value = 100

takeDamage(factor=1) {
this.value -= Math.random() * factor
}
}
ecs.assign(player, Health)
ecs.get(player, Health).takeDamage()

Lifecycle

It is sometimes useful to run logic when a component is assigned or removed to or from an entity. Component types can optionally implement the Component interface which exposes the onAssign and onRemove methods. They will be called when the component instance is assigned to an entity or removed from an entity and can be used for setup, cleanup, and resource management.

import { PhysicsEngine } from 'your-physics-engine'
class Physics {
body
onAssign(e) {
PhysicsEngine.addBody(this.body)
}
onRemove(e) {
PhysicsEngine.removeBody(this.body)
}
}
const physicsObject = ecs.create()

// calls onAssign
ecs.assign(physicsObject, Physics, { body: PhysicsEngine.newBody() })

// calls onRemove
ecs.remove(physicsObject, Physics)

// would also call onRemove
ecs.destroy(physicsObject)

Systems

You can query for entities that has been assigned a set of components using each which returns an optimized generator of matching entities usable in a for...of loop. The order of iteration is undefined and should not be relied on.

// apply gravity to all enemies
for(const e of ecs.each(Position, Enemy)) {
// e is guaranteed to have at least Position and Enemy components
ecs.get(e, Position).y -= 0.1
}

Because its just normal iteration you can organize your logic however you want.

function applyEnemyGravity(ecs, amount=0.1) {
for(const e of ecs.each(Position, Enemy)) {
ecs.get(e, Position).y -= amount
}
}

applyEnemyGravity(ecs, 0.5)

Deferring Operations

Three methods make visible mutations to the Ecs instance

  • assign -- assigns a component to an entity
  • remove -- removes a component from an entity
  • destroy -- destroys an entity, removing all components assigned to it

These mutations are visible in iterations using each and when reading with has and get.

By default the changes are immediately visible which is simple and intuitive. There are situations where you might want to defer these operations to take place later, for example at the end of a frame so that all ECS operations during a frame take place against a "stable world".

The three methods above take a defer boolean as their last argument. It is optional and defaults to the value passed into the Ecs constructor, allowing you to configure your ECS to defer by default and/or determine deferral on a per-operation basis.

Deferred operations take effect when you call commit

const engine = new GameEngine()
const ecs = new Ecs({ defer: true })
engine.update = function() {
for(const e of ecs.each(Position, EnemySpawner)) {
if(Math.random() < 0.1) {
const newEnemy = ecs.create()
ecs.assign(newEnemy, Position, ecs.get(e, Position))
ecs.assign(newEnemy, Enemy)
}
}

for(const e of ecs.each(Position, Enemy)) {
// any enemies added by the spawner in the above loop
// *not visible here* until next frame
}

// end of frame
ecs.commit() // actually add enemies created in first loop
}