tessl install tessl/npm-ecos@0.2.0Entity Component System for JavaScript that enables component-based entity creation and management using a factory pattern
This guide presents proven design patterns for building effective entity systems with ECOS. These patterns help you work within the framework's constraints while building maintainable, scalable applications.
Centralize factory creation and access:
const { factory } = require('ecos');
class FactoryRegistry {
constructor() {
this.factories = new Map();
}
register(name, config) {
const factoryInstance = factory.create(config);
this.factories.set(name, factoryInstance);
return factoryInstance;
}
get(name) {
return this.factories.get(name);
}
create(factoryName, options) {
const factoryInstance = this.get(factoryName);
if (!factoryInstance) {
throw new Error(`Factory '${factoryName}' not found`);
}
return factoryInstance.create(options);
}
}
// Usage
const registry = new FactoryRegistry();
registry.register('player', {
name: 'player',
props: ['health', 'mana'],
default: { health: 100, mana: 50 }
});
registry.register('enemy', {
name: 'enemy',
props: ['health', 'damage'],
default: { health: 50, damage: 10 }
});
// Create entities through registry
const player = registry.create('player', { health: 150 });
const enemy = registry.create('enemy');Define factories using configuration objects:
const { factory, extenders } = require('ecos');
// Define configurations
const entityConfigs = {
player: {
name: 'player',
presets: ['positioned', 'mortal', 'tracked'],
default: {
x: 0,
y: 0,
health: 100,
maxHealth: 100
}
},
enemy: {
name: 'enemy',
presets: ['positioned', 'mortal'],
default: {
x: 0,
y: 0,
health: 50,
maxHealth: 50
}
},
projectile: {
name: 'projectile',
presets: ['positioned'],
default: {
x: 0,
y: 0,
speed: 5
}
}
};
// Create factories from config
function createFactories(configs) {
const factories = {};
for (const [key, config] of Object.entries(configs)) {
factories[key] = factory.create(config);
}
return factories;
}
const gameFactories = createFactories(entityConfigs);
// Use factories
const player = gameFactories.player.create({ x: 100, y: 100 });
const enemy = gameFactories.enemy.create({ x: 200, y: 200 });Create a builder for entities with complex initialization:
const { factory, entities } = require('ecos');
class CharacterBuilder {
constructor(characterFactory) {
this.factory = characterFactory;
this.options = {};
}
withPosition(x, y) {
this.options.x = x;
this.options.y = y;
return this;
}
withStats(health, mana) {
this.options.health = health;
this.options.maxHealth = health;
this.options.mana = mana;
this.options.maxMana = mana;
return this;
}
withInventory(items) {
this.options.initialItems = items;
return this;
}
build() {
const character = this.factory.create(this.options);
// Post-creation initialization
if (this.options.initialItems) {
character.inventory = [...this.options.initialItems];
}
return character;
}
}
// Usage
const characterFactory = factory.create({
name: 'character',
props: ['x', 'y', 'health', 'maxHealth', 'mana', 'maxMana', 'inventory'],
default: {
x: 0,
y: 0,
health: 100,
maxHealth: 100,
mana: 50,
maxMana: 50,
inventory: []
}
});
const warrior = new CharacterBuilder(characterFactory)
.withPosition(50, 50)
.withStats(150, 30)
.withInventory(['sword', 'shield'])
.build();Initialize computed properties after entity creation:
const { factory } = require('ecos');
factory.registerMethod('initialize', function() {
// Compute derived values
this.maxHealth = this.baseHealth * this.vitality;
this.maxMana = this.baseMana * this.intelligence;
// Initialize collections
this.inventory = [];
this.skills = [];
// Set computed flags
this.canFly = this.agility > 15;
this.canSwim = this.vitality > 10;
});
const characterFactory = factory.create({
name: 'character',
props: ['baseHealth', 'baseMana', 'vitality', 'intelligence', 'agility'],
default: {
baseHealth: 100,
baseMana: 50,
vitality: 10,
intelligence: 10,
agility: 10
},
methods: ['initialize']
});
// Always call initialize after create
const character = characterFactory.create({ vitality: 15, intelligence: 12 });
character.initialize();
console.log(character.maxHealth); // 1500
console.log(character.maxMana); // 600Initialize properties on first access:
const { factory, extenders } = require('ecos');
factory.registerExtender('lazyInventory', {
type: extenders.GETSET,
name: 'inventory',
get: function() {
if (!this._inventory) {
this._inventory = [];
}
return this._inventory;
}
});
factory.registerExtender('lazyMetadata', {
type: extenders.GETSET,
name: 'metadata',
get: function() {
if (!this._metadata) {
this._metadata = {};
}
return this._metadata;
}
});
const entityFactory = factory.create({
name: 'entity',
extend: ['lazyInventory', 'lazyMetadata']
});
const entity = entityFactory.create();
// No memory used until accessed
console.log(entity.inventory); // [] (created on first access)
entity.inventory.push('item');
console.log(entity.inventory); // ['item']Define a template for entity initialization:
const { factory } = require('ecos');
factory.registerMethod('postCreate', function() {
this.onCreate();
this.initializeCollections();
this.computeStats();
this.onReady();
});
factory.registerMethod('onCreate', function() {
// Override in specific factories
});
factory.registerMethod('initializeCollections', function() {
this.inventory = [];
this.buffs = [];
this.debuffs = [];
});
factory.registerMethod('computeStats', function() {
this.maxHealth = this.baseHealth * this.vitality;
});
factory.registerMethod('onReady', function() {
// Override in specific factories
});
// Create base factory
const characterFactory = factory.create({
name: 'character',
props: ['baseHealth', 'vitality'],
default: { baseHealth: 100, vitality: 10 },
methods: ['postCreate', 'onCreate', 'initializeCollections', 'computeStats', 'onReady']
});
// Usage
const character = characterFactory.create();
character.postCreate();Manage hierarchical relationships:
const { factory, entities } = require('ecos');
factory.registerMethod('addChild', function(childId) {
if (!this.childIds) {
this.childIds = [];
}
this.childIds.push(childId);
});
factory.registerMethod('removeChild', function(childId) {
if (!this.childIds) return;
const index = this.childIds.indexOf(childId);
if (index > -1) {
this.childIds.splice(index, 1);
entities.remove(childId);
}
});
factory.registerMethod('getChildren', function() {
if (!this.childIds) return [];
return this.childIds.map(id => entities.get(id)).filter(Boolean);
});
factory.registerMethod('destroy', function() {
// Recursively destroy children
if (this.childIds) {
this.childIds.forEach(id => {
const child = entities.get(id);
if (child && child.destroy) {
child.destroy();
}
});
}
// Remove self
entities.remove(this.id);
});
const nodeFactory = factory.create({
name: 'node',
props: ['data', 'childIds'],
default: { data: null, childIds: [] },
methods: ['addChild', 'removeChild', 'getChildren', 'destroy']
});
// Usage
const parent = nodeFactory.create({ data: 'root' });
const child1 = nodeFactory.create({ data: 'child1' });
const child2 = nodeFactory.create({ data: 'child2' });
parent.addChild(child1.id);
parent.addChild(child2.id);
console.log(parent.getChildren().length); // 2
// Clean up
parent.destroy(); // Removes parent and all childrenManage entity references safely:
const { entities } = require('ecos');
class ReferenceManager {
constructor() {
this.references = new Map(); // entity ID -> array of referencing entity IDs
}
addReference(fromId, toId) {
if (!this.references.has(toId)) {
this.references.set(toId, []);
}
this.references.get(toId).push(fromId);
}
removeReference(fromId, toId) {
const refs = this.references.get(toId);
if (!refs) return;
const index = refs.indexOf(fromId);
if (index > -1) {
refs.splice(index, 1);
}
if (refs.length === 0) {
this.references.delete(toId);
}
}
getReferences(entityId) {
return this.references.get(entityId) || [];
}
canRemove(entityId) {
const refs = this.getReferences(entityId);
return refs.length === 0;
}
safeRemove(entityId) {
if (!this.canRemove(entityId)) {
throw new Error(`Cannot remove entity ${entityId}: still referenced`);
}
entities.remove(entityId);
this.references.delete(entityId);
}
}
// Usage
const refManager = new ReferenceManager();
const item = itemFactory.create({ name: 'Sword' });
const player = playerFactory.create({ name: 'Hero' });
// Player references item
player.equippedId = item.id;
refManager.addReference(player.id, item.id);
// Try to remove item
try {
refManager.safeRemove(item.id); // Error: still referenced
} catch (error) {
console.log(error.message);
}
// Remove reference first
player.equippedId = null;
refManager.removeReference(player.id, item.id);
// Now can remove
refManager.safeRemove(item.id); // SuccessCompose entities from reusable components:
const { factory, entities } = require('ecos');
factory.registerMethod('addComponent', function(componentId) {
if (!this.componentIds) {
this.componentIds = [];
}
this.componentIds.push(componentId);
});
factory.registerMethod('getComponent', function(type) {
if (!this.componentIds) return null;
for (const id of this.componentIds) {
const component = entities.get(id);
if (component && component.type === type) {
return component;
}
}
return null;
});
factory.registerMethod('hasComponent', function(type) {
return this.getComponent(type) !== null;
});
// Component factories
const positionFactory = factory.create({
name: 'position',
props: ['x', 'y'],
default: { x: 0, y: 0 }
});
const healthFactory = factory.create({
name: 'health',
props: ['current', 'max'],
default: { current: 100, max: 100 }
});
const entityFactory = factory.create({
name: 'entity',
props: ['componentIds'],
default: { componentIds: [] },
methods: ['addComponent', 'getComponent', 'hasComponent']
});
// Usage
const entity = entityFactory.create();
const position = positionFactory.create({ x: 10, y: 20 });
const health = healthFactory.create({ current: 80, max: 100 });
entity.addComponent(position.id);
entity.addComponent(health.id);
if (entity.hasComponent('position')) {
const pos = entity.getComponent('position');
console.log(pos.x, pos.y); // 10, 20
}Implement entity state machines:
const { factory } = require('ecos');
factory.registerMethod('setState', function(newState) {
const oldState = this.state;
// Exit old state
if (this[`onExit${oldState}`]) {
this[`onExit${oldState}`]();
}
// Change state
this.state = newState;
// Enter new state
if (this[`onEnter${newState}`]) {
this[`onEnter${newState}`]();
}
});
factory.registerMethod('onEnterIdle', function() {
console.log('Entering idle state');
this.speed = 0;
});
factory.registerMethod('onExitIdle', function() {
console.log('Exiting idle state');
});
factory.registerMethod('onEnterMoving', function() {
console.log('Entering moving state');
this.speed = this.baseSpeed;
});
factory.registerMethod('onEnterAttacking', function() {
console.log('Entering attacking state');
this.speed = 0;
});
const characterFactory = factory.create({
name: 'character',
props: ['state', 'speed', 'baseSpeed'],
default: {
state: 'Idle',
speed: 0,
baseSpeed: 5
},
methods: [
'setState',
'onEnterIdle',
'onExitIdle',
'onEnterMoving',
'onEnterAttacking'
]
});
// Usage
const character = characterFactory.create();
character.setState('Moving'); // Logs: Exiting idle state, Entering moving state
character.setState('Attacking'); // Entering attacking stateNotify observers of entity changes:
const { factory } = require('ecos');
factory.registerMethod('addObserver', function(observer) {
if (!this.observers) {
this.observers = [];
}
this.observers.push(observer);
});
factory.registerMethod('removeObserver', function(observer) {
if (!this.observers) return;
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
});
factory.registerMethod('notify', function(event, data) {
if (!this.observers) return;
this.observers.forEach(observer => {
if (observer.onNotify) {
observer.onNotify(this, event, data);
}
});
});
factory.registerMethod('takeDamage', function(amount) {
const oldHealth = this.health;
this.health = Math.max(0, this.health - amount);
this.notify('healthChanged', {
oldHealth,
newHealth: this.health,
damage: amount
});
if (this.health === 0) {
this.notify('died', {});
}
});
const characterFactory = factory.create({
name: 'character',
props: ['health', 'observers'],
default: { health: 100, observers: [] },
methods: ['addObserver', 'removeObserver', 'notify', 'takeDamage']
});
// Create observer
const healthBar = {
onNotify(entity, event, data) {
if (event === 'healthChanged') {
console.log(`Health: ${data.newHealth}/${entity.health}`);
} else if (event === 'died') {
console.log('Character died!');
}
}
};
// Usage
const character = characterFactory.create();
character.addObserver(healthBar);
character.takeDamage(30); // Logs: Health: 70/100
character.takeDamage(80); // Logs: Health: 0/100, Character died!Encapsulate entity actions as commands:
const { entities } = require('ecos');
class Command {
execute() {
throw new Error('execute() must be implemented');
}
undo() {
throw new Error('undo() must be implemented');
}
}
class MoveCommand extends Command {
constructor(entityId, dx, dy) {
super();
this.entityId = entityId;
this.dx = dx;
this.dy = dy;
}
execute() {
const entity = entities.get(this.entityId);
entity.x += this.dx;
entity.y += this.dy;
}
undo() {
const entity = entities.get(this.entityId);
entity.x -= this.dx;
entity.y -= this.dy;
}
}
class SetHealthCommand extends Command {
constructor(entityId, newHealth) {
super();
this.entityId = entityId;
this.newHealth = newHealth;
this.oldHealth = null;
}
execute() {
const entity = entities.get(this.entityId);
this.oldHealth = entity.health;
entity.health = this.newHealth;
}
undo() {
const entity = entities.get(this.entityId);
entity.health = this.oldHealth;
}
}
class CommandManager {
constructor() {
this.history = [];
this.currentIndex = -1;
}
execute(command) {
command.execute();
// Remove any commands after current index
this.history = this.history.slice(0, this.currentIndex + 1);
// Add new command
this.history.push(command);
this.currentIndex++;
}
undo() {
if (this.currentIndex < 0) return;
const command = this.history[this.currentIndex];
command.undo();
this.currentIndex--;
}
redo() {
if (this.currentIndex >= this.history.length - 1) return;
this.currentIndex++;
const command = this.history[this.currentIndex];
command.execute();
}
}
// Usage
const manager = new CommandManager();
const entity = entityFactory.create({ x: 0, y: 0, health: 100 });
manager.execute(new MoveCommand(entity.id, 10, 5));
console.log(entity.x, entity.y); // 10, 5
manager.execute(new SetHealthCommand(entity.id, 50));
console.log(entity.health); // 50
manager.undo();
console.log(entity.health); // 100
manager.undo();
console.log(entity.x, entity.y); // 0, 0
manager.redo();
console.log(entity.x, entity.y); // 10, 5Create indices for efficient entity queries:
const { entities } = require('ecos');
class EntityIndex {
constructor() {
this.byType = new Map();
this.byProperty = new Map();
}
add(entity) {
// Index by type
if (!this.byType.has(entity.type)) {
this.byType.set(entity.type, new Set());
}
this.byType.get(entity.type).add(entity.id);
}
remove(entityId) {
const entity = entities.get(entityId);
if (!entity) return;
// Remove from type index
const typeSet = this.byType.get(entity.type);
if (typeSet) {
typeSet.delete(entityId);
}
}
getByType(type) {
const ids = this.byType.get(type) || new Set();
return Array.from(ids).map(id => entities.get(id)).filter(Boolean);
}
indexProperty(entityId, propertyName, value) {
const key = `${propertyName}:${value}`;
if (!this.byProperty.has(key)) {
this.byProperty.set(key, new Set());
}
this.byProperty.get(key).add(entityId);
}
getByProperty(propertyName, value) {
const key = `${propertyName}:${value}`;
const ids = this.byProperty.get(key) || new Set();
return Array.from(ids).map(id => entities.get(id)).filter(Boolean);
}
}
// Usage
const index = new EntityIndex();
const player = playerFactory.create({ team: 'red' });
const enemy1 = enemyFactory.create({ team: 'blue' });
const enemy2 = enemyFactory.create({ team: 'blue' });
index.add(player);
index.add(enemy1);
index.add(enemy2);
index.indexProperty(player.id, 'team', 'red');
index.indexProperty(enemy1.id, 'team', 'blue');
index.indexProperty(enemy2.id, 'team', 'blue');
// Query
const players = index.getByType('player');
const enemies = index.getByType('enemy');
const blueTeam = index.getByProperty('team', 'blue');
console.log(players.length); // 1
console.log(enemies.length); // 2
console.log(blueTeam.length); // 2Reuse entities to reduce garbage collection:
const { entities } = require('ecos');
class EntityPool {
constructor(factory, initialSize = 10) {
this.factory = factory;
this.available = [];
this.active = new Set();
// Pre-create entities
for (let i = 0; i < initialSize; i++) {
const entity = factory.create();
this.available.push(entity.id);
}
}
acquire(options = {}) {
let entityId;
if (this.available.length > 0) {
// Reuse existing entity
entityId = this.available.pop();
} else {
// Create new entity
const entity = this.factory.create();
entityId = entity.id;
}
// Reset and configure entity
const entity = entities.get(entityId);
Object.assign(entity, options);
this.active.add(entityId);
return entity;
}
release(entityId) {
if (!this.active.has(entityId)) return;
this.active.delete(entityId);
this.available.push(entityId);
// Reset entity state
const entity = entities.get(entityId);
// Reset properties to defaults...
}
clear() {
this.active.forEach(id => this.release(id));
}
}
// Usage
const projectileFactory = factory.create({
name: 'projectile',
props: ['x', 'y', 'vx', 'vy', 'active'],
default: { x: 0, y: 0, vx: 0, vy: 0, active: false }
});
const pool = new EntityPool(projectileFactory, 50);
// Acquire projectile
const projectile = pool.acquire({ x: 100, y: 100, vx: 5, vy: 0, active: true });
// Use projectile...
// Release back to pool
pool.release(projectile.id);