@fritzy/ecs
An Entity-Component-System library for JavaScript, written in ECMAScript 2018, intended for use in games and simulations.
Features:
- Easy to define Components.
- Component properties can be primitive types, Arrays, Objects, or References.
- Component reference types to Entities and Components (including Objects and Arrays of references)
- Components can be singular per entity or allow multiple.
- Multi-Components can be sets or mapped by a property.
- Query for entities by which components it must and must not have.
- Filter component queries by recent changes to component values or included components.
- Systems can subscribe to component change logs by component type.
- Systems have default entity queries that are persisted.
- Persisted queries are updated as entities change.
- Export/import support for saving state.
- 100% Test Coverage.
About:
The Entity-Component-System paradigm is great for managing dynamic objects in games and simulations. Instead of binding functionality to data through methods, systems are able to freely manipulate data directly, so long as they have the datatypes it expects and filters for. This encourages dynamic composition of Entities and systems that can freely interact through shared data.
This arrangement of dynamic data types within an object and freely interacting systems leads to:
- more complex types
- improved performance due to lack of API methods
- emergent gameplay with logical behaviors that the programmer didn't necessarily directly envision
This library is not a strict/pure Entity-Component-System library for a few reasons:
- Entities aren't just ids that component can have in common -- they're classes that have properties for all of their components.
- Components are a little more advanced than just data, but we try to make it feel that way. Components are models with advanced features.
- All of the entities, components, and systems are managed by an ECS class as a sort of registery.
I built this library around the ideas and scenarios best illustrated by this Overwatch Gameplay Architecture and Netcode video (only the first half is very relevant).
Using This Library
Reference Index
- constructor
- registerComponent method
- registerComponentClass method
- registerTags method
- createEntity method
- removeEntity method
- getEntity method
- queryEntities method
- getComponents method
- addSystem method
- runSystemGroup method
- tick method
- Properties
- Component Properties
- constructor
- addComponent method
- removeComponent method
- removeComponentByType method
- addTag method
- removeTag method
- getObject method
- destroy method
Example Game
Roguelike Example Using @fritzy/ecs + rot.js
Install
npm install @fritzy/ecs
Tests
The goal is to keep test coverage at 100%.
git clone git@github.com/fritzy/ecs-js.git
cd ecs-js
npm install
npm test
ECS
The main class of this library manages all of the entities and components.
constructor
Arguments: None
const ECS = require('@fritzy/ecs');
const ecs = new ECS.ECS();
ecs.registerComponent('Tile', { });
// ...
registerComponent method
Register a component type by defining the name, properties, and options for a component.
Arguments:
- [string] name
- [object] definition
Defintions have the following structure:
{
properties: {
property_name: 'default value',
property_name2: 'default value',
some_advanced_property: '<Entity>' // special types are set with <type>.
},
multiset: false // [boolean],
mapBy: 'name' // if multiset and set to true,
serilize: {
skip: false,
serialize.ignore: []
}
}
You have have any number of properties. Advanced types are currently:
- <Entity>
- <EntitySet>
- <EntityObject>
- <Component>
- <ComponentSet>
- <ComponentObject>
- <Pointer path.to.other.value>
If multiset is false (default), then each entity can only have one instance of a component. You'll be able to access the instance in an entity by name like:
entity['ComponentName']
If multiset is true, then many instances of a the component can be in an component. You'll be able to access them:
for (const component of entity['ComponentName']) {
// ...
}
If multieset is true, and mapBy
is one of the property names, you can access the multiple instances by the mapBy property value.
entity['ComponentName']['value_of_the_mapBy_property']
Here is an example of each configuration of component.
//basic
ecs.registerComponent('ControlledByPlayer', {
properties: {
numberOfTurns: 0
}
});
// multiset = false
ecs.registerComponent('Weapon', {
properties: {
name: 'sword',
dmg: 30,
hitChance: .8,
}
});
// multiset = true
ecs.registerComponent('StatBonus', {
properties: {
from: '',
hp: 0,
dex: 0,
int: 0
},
multiset: true
});
// multislot with mapBy
ecs.registerComponent('EquipmentSlot', {
properties: {
name: 'hand',
slot: '<Entity>'
},
multiset: true,
mapBy: 'name'
});
const sword = ecs.createEntity({
Weapon: {
name: 'sword of whatever',
dmg: 7,
hitChance: .67
}
});
const player = ecs.createEntity({
ControlledByPlayer: {}
StatBonus: [
{
from: 'ring of intelligence',
int: 3
},
{
from: 'shoulders of moving fast',
dex: 2
},
{
from: 'boots of constitution',
hp: 5
},
],
EquipmentSlot: {
leftHand: {},
rightHand: {}
}
});
player.EquipmentSlot.rightHand.slot = sword;
console log(player.EquipmentSlot.rightHand.slot.name); // sword of whatever
const bonuses = [...player.StatBonus];
console.log(bonsuses[1].dex); // 2
console.log(player.ControlledByPlayer.numberOfTurns); // 0
The serial
section of deals with calling getObject
and stringify
either on the Component instance itself or the Entity. Advanced reference types are serialized with their id. Some types just can't be reasonably serialized, and so this serialize
definition includes ways to avoid serializing an entire component type or specific properties.
{
skip: false, // set to true to ingore the whole component when serializing
ignore: [] // array of string property names to avoid serializing
}
If you had a Sprite component, you might have path
and texture
properties. You may want to add 'texture'
to the skip.ignore
array, so that it isn't serialized, but could be populated after using the serialized object with createEntity
by loading the texture from the path
property.
Serialized entities and components are useful for save files, and can be used to restore state with createEntity
.
registerComponentClass method
Instead of passing the definition to registerComponent
you can extend the BaseComponent
class directly, and attach your definition object (as described in registerComponent) as property directly on the class.
registerComponent
.
Arguments:
- [class] klass // class reference (not an instance) that extends BaseComponent and has an attached
definition
.
const ECS = require('ecs');
const ecs = new ECS.ECS();
class MyComponent extends ECS.BaseComponent {
}
MyComponent.definition = {
properties: {
name: 'hand',
slot: '<Entity>'
},
multiset: true,
mapBy: 'name'
};
ecs.registerComponentClass(MyComponent);
registerTags method
Arguments:
- [array of strings]: Allowed tags
createEntity method
Create an entity and populate it's initial componenents with values.
Arguments:
- [object] initial values
The root keys to the definition object must be one of the component type names or id
. For every component type, you must have previously registered it with registerComponent or registerComponentClass. For each component intiialized in the entity definition, you may only use keys that were defined as properties when the component was registered.
If the component was not set with multiset
as true
, then the component value is simply an object of component properties with values. If multiset was set to true, and no mapBy
was defined, then you can defined an array with each entry being an object with properties and values. If mapBy
was set, you can have an object of string or number values of the property that mapBy
refers to that each references an object of properties and values, excluding the mapBy property which will be automatically assigned.
If you specify tags
as an array in the object, then your entity will initialize with those tags. They must have been registered as possible tags.
removeEntity method
Removes an entity from the ECS instance by it's id.
Arguments:
- [string] entity id
getEntity method
Returns an entity by id.
Arguments:
- [string] entity id
Returns: Entity instance
queryEntities method
Query for entites, filtered by various parameters. Queries may be persisted, an optimization which keeps and updates results for the next use.
If you use this from a system update, set persist to a unique string and it will keep the results updated for the next time it is run with the same persist string.
Arguments:
- [object]:
- has: [array of strings] component types or tags that an entity must have
- hasnt: [array of strings] component types or tags that an entity must not have
- persist: [false or string] persist and maintain results
- updatedValues [number] filter out entities that haven't had component value updates since this tick
- updatedComponents [number] filter out entities that haven't had components added/removed since this tick
Default argument object:
{
has: [],
hasnt: [],
persist: false,
updatedValues: 0,
updatedComponents: 0
}
Returns: Set of Entity instances
getComponents method
Get a set of all components of a given type.
Arguments:
- [string] component type
addSystem method
Add a system to the ECS instance, wihin a group.
Arguments:
- [string] group name
- [System instance]
runSystemGroup method
Runs the systems of a given group, in the order added.
Arguments:
- [string] group name
tick method
Iterate the tick. Useful for for systems to track logical frames and to filter query results. Currently this is equivalent to ecs.ticks++
but other logic and optimizations may be added in the future, related to frame tracking.
Returns: [number] new frame tick number
Component
Components are the building blocks and models of the Entity-Component-System paradigm.
constructor
. Components are locked down with Object.seal(), keeping their properties strict.
definition
Accessing Properties
You can access defined properties directly on the class instance, which you likely are accessing through an Entity instance.
You cannot set or get properties that weren't defined in the Component definition because Component instances are sealed with Object.seal() in the constructor.
Properties can be accessed directly off of the component instance, but involve getters and setters to hidden (non-iterable properties) on the instance to enable several features. For example, setting a basic property value with set the lastUpdated property to the current ecs
tick (used by query filters or features you make in Systems). Advanced Reference properties store the Component or Entity ids themselves regardless of whether you assign a string id or the instance, and return the instance with the getter when accessed as a kind of weak-reference.
There are also some built in properties:
- entity: Entity instance that the component belongs to
- id: string id of the component
constructor
Rather than initializing Components with the new
keyword, use the ECS-Entity factory ecs.createEntity or the Entity-Component factory entity.addComponent instead.
getObject method
Returns an object of properties and values from the Component, including the id, ignoring any properties in the definition.serialize.ignore
array. This object may be used as the intial values for a new Component with ecs.createEntity or entity.addComponent.
stringify method
The same as component.getObject but it returns a serialized version using JSON.stringify
Entity
Entity instances keep references to its components, accessed with the property named after each Component type.
Properties
- ecs: ECS instance that the entity belongs to
- id: string id of the entity
- updatedComponents: tick of last when a component was added or removed
- updatedValues: tick of last when a component property changed
Entities also have properties for each component type that has been added.
Component Properties
If the Component defintion has multiset set to false (the default), then you can access the component instance directly off of the component type name.
entity.Position.x = 34; // set the value of x on the Position component of the entity
If the Component has multiset set to true, then the property will reference a Set
of components
for (const bonus of entity.Bonus) {
console.log(bonus.hp);
}
If the Component has multiset to true and mapBy set to a property name, then the property will reference an Object
with properties of the property of each component referenced by mapBy
.
ecs.registerComponent('EquipmentSlot', {
properties: {
slotName: 'hand',
item: '<Entity>',
open: true
}
});
const entity = ecs.createEntity({
EquipmentSlot: {
legs: { open: true },
body: { open: true },
leftHand: { open: true },
rightHand: { open: true }
}
});
entity.addComponent('EquipmentSlot', {
slotName: 'feet',
open: false
});
entity.EquipmentSlot.leftHand.item = someOtherEntity;
console.log(EquipmentSlot.body.slotName); // "body"
console.log(EquipmentSlot.feet.open); // false
constructor
Rather than initializing Entities with the new
keyword, use the ECS-Entity factory ecs.createEntity.
addComponent method
A factory that creates a Component instance and applies it to the Entity instance. You can add set the initial values of the component.
Arguments:
- [string] Component Type
- [objected] initial values
removeComponentByType method
Remove all Component instances from the Entity instance by the Component type name (string).
Arguments:
- [string] Component type
addTag method
Adds a tag to the entity. Must have been registered.
Arguments:
- [string] Tag
removeTag method
Removes a tag to the entity.
Arguments:
- [string] Tag
removeComponent method
Remove a component from the Entity instance by instance or id.
Arguments:
- [string] Component id or [Component instance]
getObject method
Returns an object of types with their proeprties and values, including the id, ignoring any properties in the definition.serialize.ignore
array. This object may be used as the intial values for a new Component with ecs.createEntity or entity.addComponent.
stringify method
The same as entity.getObject but it returns a serialized version using JSON.stringify
destroy method
Destroy the Entity instance, it's components, and removing any refrences to it or its components in other components. You will no longer be able to look up the Entity instance by id unless you make a new one with the same id.
System
Systems are classes that you extend from ECS.System and override the update method to implement your system logic.
System classes can also have a query
object with has
and hasnt
properties, and a subcriptions
array of component type names or tags attached to the constructor/class.
Here is an example of a simple system:
const ECS = require('@fritzy/ecs');
const ecs = new ECS.ECS();
ecs.registerComponent('Body', {
properties: {
mass: 1
}
});
ecs.registerComponent('Position', {
properties: {
x: 0,
y: 0,
xVel: 0,
yVel: 0
}
});
ecs.registerComponent('Static', {
properties: {}
});
ecs.registerComponent('Impulse', {
properties: {
x: 0,
y: 0
}
});
const ball = ecs.createEntity({
Body: {},
Position: { x: 100, y: -100 }
});
const peg = ecs.CreateEntity({
Body: {},
Position: { x: 100, y: -150 }
Static: {}
});
class Gravity extends ECS.System {
//feel free to override the constructor, but pass the first parameter to `super()`.
constructor(ecs) {
super(ecs);
}
update(tick, entities) {
//tick is your current tick
//entities is the result of your default query
//you can run other queries as well by using this.ecs.queryEntities()
//this.lastTick tells you the tick this system last ran
for (const entity of entities) {
entity.Position.yVel += .1;
entity.Position.y += entity.Position.yVel;
}
//this.changes is an array of changes from your subscriptions
for (const change of this.changes) {
if (change.component.type !== 'Impulse') break;
if (change.op !== 'addComponent') break;
const impulse = change.component;
const entity = change.component.entity;
if (!entity.hasOwnProperty('Position')) break;
j
entity.Position.xVel += impulse.x;
entity.Position.yVel += impulse.y;
entity.removeComponent(component);
}
//the changes array is cleared every time the system is run
}
}
// setting a query will give you a result set with every update
// the query results are kept up to date as enties are created, destroyed, add components, and remove components
Gravity.query = {
has: ['Body', 'Position'],
hasnt: ['Static']
};
// setting a subscription will result in a change log for those types
Gravity.subscriptions = ['Impulse'];
//you can pass addSystem the class or the instance
ecs.addSystem('physics', Gravity);
function update() {
//you could store time delta information in an entity with a well-known id here
ecs.runSystemGroup('physics');
window.requestAnimationFrame(update);
}
window.requestAnimationFrame(update);
You'd likely have lots of systems, like one for rendering, one for gathering inputs and applying them, etc.
constructor
You can override the base System constructor, but it isn't necessary. If you do, be sure to pass the first argument to super.
Arguments:
- [ECS] ecs instance
Properties
- ecs: ecs instance
- lastTick: tick from the last time the system ran
- changes: array of changes
Changes
If you've set a subscription
array on your system constructor (or class), then your changes
property will start populating with changes as the component types that you've subscribed to are added and their properties change.
The changes array is cleared every time after the system runs.
Each change object has the following properties:
- component: the component instance that changed
- op: the operation name indicating what action took place
- key: the name of the property or index that changed
- old: the previous value of the property
- value: the current value of the property
Possible operations:
- addComponent: when the component is fully initialized (no key, old, or value)
- setEntity: when an
<Entity>
property is set - setComponent: when a
<Component>
property is set - addEntitySet: when an
<EntitySet>
property is added to - deleteEntitySet: when an
<EntitySet>
property is deleted from - clearEntitySet: when an
<EntitySet>
property is cleared - addComponentSet: when an
<ComponentSet>
property is added to - deleteComponentSet: when an
<ComponentSet>
property is deleted from - clearComponentSet: when an
<ComponentSet>
property is cleared - setEntityObject: when a
<EntityObject>
property is set - deleteEntityObject: when a
<EntityObject>
property is deleted - setComponentObject: when a
<ComponentObject>
property is set - deleteComponentObject: when a
<ComponentObject>
property is deleted
update method
Called every time the system is run, override this method to write the behavior of the System. Don't call this yourself, instead use ecs.runSystemGroup.
Arguments:
- [number] current tick
- [array of entity instances] entities resulting from query