Modular ECS architecture implementation for composing simulation and game-like systems' behaviors.
While this library implements a generic ECS, its main goal is to provide a friendly API for combining agent-behavior systems and achieving complex results easily. An example is the classic boid swarm, which can be built by combining Separation, Alignment, and Cohesion systems:
separation → alignment → cohesion → movement → render pipeline
- Cached queries with constant-time lookup.
- Bitmask-based archetype resolution limited to 32 components per ECS instance.
- Queries with support for ALL, ANY, and NONE operators.
- Simple stage-based system execution ordering.
- Typed components API.
- OOP-like entity/component interface.
- Archetypal model but without focus on memory layout/cache locality for now.
const ecs = ECS();
const entity = ecs.Entity();
New types of components can be declared using the ComponentType<Schema>()
function.
const Position = ComponentType<{ x: number, y: number }>();
const Velocity = ComponentType<{ velocity: number }>();
And instances of a specific component type can be created through its create()
function.
entity.addComponent(Position.create({ x: 50, y: 50 }));
entity.addComponent(Velocity.create({ velocity: 0 }));
Systems are built using a query that describes the type of entities that this system will process, an update function to process these entities on each ECS "run", and a stage that specifies when the update function runs.
ecs.System('movement', Stages.UPDATE,
ecs.Query(Position, Velocity), (entities) => {
for (const entity of entities) {
const position = entity.getComponent(Position).data;
const velocity = entity.getComponent(Velocity).data;
if (!(position && velocity)) { return; }
position.x += velocity;
position.y += velocity;
}
}
);
Queries used by systems support combining all
, any
and none
operators in order to have more control over the types of entities that a system receives.
For example a system that runs only on entities that are "static" could be expressed like:
ecs.System('example', Stages.UPDATE,
ecs.Query({ none: [Velocity] }), (entities) => {
// process entities...
}
);
Or a more complex query to process al "adversary" entities that are not visible and without health:
ecs.System('example', Stages.UPDATE,
ecs.Query({ all: [Adversary], none: [Health, Visible] }), (entities) => {
// process entities...
}
);
Systems are run in order of registration, but also in order of their defined stages. Currently the ECS has a set of defined stages that run in the following order:
- INIT
- POST_INIT
- PRE_UPDATE
- UPDATE
- POST_UPDATE
- PRE_RENDER
- RENDER
Example usage:
ecs.System('A', Stages.PRE_UPDATE, ecs.Query(Position), (entities) => {});
ecs.System('B', Stages.UPDATE, ecs.Query(Position), (entities) => {});
ecs.System('C', Stages.PRE_UPDATE, ecs.Query(Position), (entities) => {});
ecs.System('D', Stages.RENDER, ecs.Query(Position), (entities) => {});
This setup will run the systems in order: A
-> C
-> B
-> D
The ECS run()
function runs all systems once, meaning that the update function of each registered system is called once in the specified stage/registration order:
ecs.run();
A common setup for "infinite" system iteration is the classic game-like loop:
const running = true;
while (running) {
ecs.run();
}