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 documents all important limitations, gotchas, and constraints in ECOS. Understanding these constraints helps you avoid common pitfalls and design better entity systems.
The most important constraint: Extenders run before properties from default or create(options) are available.
const { factory, extenders } = require('ecos');
// ✗ WRONG: This will not work as expected
factory.registerExtender('badExample', {
type: extenders.FUNCTION,
handler: function(entity) {
// baseHealth is undefined here!
entity.maxHealth = entity.baseHealth * 2;
}
});
const factory1 = factory.create({
name: 'character',
props: ['baseHealth'],
default: { baseHealth: 100 },
extend: ['badExample']
});
const char = factory1.create();
console.log(char.maxHealth); // NaN (undefined * 2)Solutions:
factory.registerMethod('computeMaxHealth', function() {
this.maxHealth = this.baseHealth * 2;
});
const factory2 = factory.create({
name: 'character',
props: ['baseHealth'],
default: { baseHealth: 100 },
methods: ['computeMaxHealth']
});
const char2 = factory2.create();
char2.computeMaxHealth();
console.log(char2.maxHealth); // 200factory.registerExtender('maxHealthGetter', {
type: extenders.GETSET,
name: 'maxHealth',
get: function() {
return this.baseHealth * 2;
}
});
const factory3 = factory.create({
name: 'character',
props: ['baseHealth'],
default: { baseHealth: 100 },
extend: ['maxHealthGetter']
});
const char3 = factory3.create();
console.log(char3.maxHealth); // 200 (getter accesses current baseHealth)See Execution Order for details.
Critical limitation: Preset props do NOT add new properties to entities. They only initialize properties that already exist in the factory's default option.
// ✗ WRONG: Will cause TypeError at runtime
factory.registerPreset('positioned', {
props: ['x', 'y']
});
const badFactory = factory.create({
name: 'entity',
presets: ['positioned']
// Missing default: { x: 0, y: 0 } - TypeError will occur!
});
// ✓ CORRECT: Props from preset are in default
const goodFactory = factory.create({
name: 'entity',
presets: ['positioned'],
default: { x: 0, y: 0 } // Required!
});Why this happens: Preset props are a design quirk of ECOS. They don't actually add properties; they only specify which properties should be initialized during entity creation. If those properties don't exist in default, the initialization fails.
Best practice: Always ensure factory default includes all properties referenced in preset props:
// Register preset with props
factory.registerPreset('mortal', {
props: ['health', 'maxHealth'],
methods: ['takeDamage']
});
// Factory MUST include these props in default
const characterFactory = factory.create({
name: 'character',
presets: ['mortal'],
default: {
health: 100, // Required
maxHealth: 100 // Required
}
});Entity IDs are sequential integers starting from 0. You cannot specify custom IDs.
const { factory, entities } = require('ecos');
const myFactory = factory.create({
name: 'entity',
props: ['value']
});
const entity1 = myFactory.create({ value: 1 });
const entity2 = myFactory.create({ value: 2 });
const entity3 = myFactory.create({ value: 3 });
console.log(entity1.id); // 0
console.log(entity2.id); // 1
console.log(entity3.id); // 2
// Cannot set custom ID
entity1.id = 999; // This changes the property but doesn't update the container
console.log(entities.get(999)); // null (entity is still at ID 0)
console.log(entities.get(0)); // entity1 (ID 0 still works)Implications:
removeAll())// Use a separate property for custom identifiers
factory.registerExtender('customId', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.uuid = Math.random().toString(36).substr(2, 9);
}
});
const entityFactory = factory.create({
name: 'entity',
extend: ['customId']
});
const entity = entityFactory.create();
console.log(entity.id); // 0 (auto-assigned)
console.log(entity.uuid); // 'a1b2c3d4e' (custom)Once registered, methods, extenders, and presets cannot be re-registered with the same name:
// First registration succeeds
factory.registerMethod('move', function(dx, dy) {
this.x += dx;
this.y += dy;
});
// Second registration fails
try {
factory.registerMethod('move', function(dx, dy) {
// Different implementation
this.x += dx * 2;
this.y += dy * 2;
});
} catch (error) {
console.error(error.message); // "Method 'move' already registered"
}Solution: Use factory.reset() to clear registrations (useful for testing):
factory.reset(); // Clear all registrations
// Now can re-register
factory.registerMethod('move', function(dx, dy) {
this.x += dx * 2;
this.y += dy * 2;
});Warning: factory.reset() clears ALL methods, extenders, and presets. Use with caution.
You must register methods before creating a factory that references them:
// ✗ WRONG: Method not registered yet
try {
const factory1 = factory.create({
name: 'entity',
methods: ['move'] // Error: 'move' not registered
});
} catch (error) {
console.error(error.message);
}
// ✓ CORRECT: Register method first
factory.registerMethod('move', function(dx, dy) {
this.x += dx;
this.y += dy;
});
const factory2 = factory.create({
name: 'entity',
methods: ['move'] // Works
});Same applies to extenders and presets:
// Register first
factory.registerExtender('timestamps', { ... });
factory.registerPreset('positioned', { ... });
// Then use in factory
const myFactory = factory.create({
name: 'entity',
extend: ['timestamps'],
presets: ['positioned']
});The entities container is a Map internally, but you can only access it through provided methods:
const { entities } = require('ecos');
// ✓ Available operations
entities.get(id);
entities.remove(id);
entities.size();
entities.removeAll();
// ✗ Not available
// entities.forEach(...)
// entities.keys()
// entities.values()
// entities.entries()Implication: You cannot iterate over all entities. If you need iteration, maintain your own list:
const allEntities = [];
const myFactory = factory.create({
name: 'entity',
props: ['value']
});
const entity1 = myFactory.create({ value: 1 });
const entity2 = myFactory.create({ value: 2 });
allEntities.push(entity1);
allEntities.push(entity2);
// Now can iterate
allEntities.forEach(entity => {
console.log(entity.value);
});Entities remain in the container until explicitly removed:
const { factory, entities } = require('ecos');
const myFactory = factory.create({
name: 'entity',
props: ['value']
});
const entity = myFactory.create({ value: 42 });
console.log(entities.size()); // 1
// Setting to null doesn't remove from container
entity = null;
console.log(entities.size()); // Still 1
// Must explicitly remove
entities.remove(entity.id);
console.log(entities.size()); // 0Best practice: Implement cleanup methods for entities with children:
factory.registerMethod('destroy', function() {
// Clean up child entities
if (this.childIds) {
this.childIds.forEach(id => entities.remove(id));
}
// Remove self
entities.remove(this.id);
});The ID counter only resets to 0 when calling entities.removeAll():
const { factory, entities } = require('ecos');
const myFactory = factory.create({ name: 'entity' });
// Create and remove
const entity1 = myFactory.create();
console.log(entity1.id); // 0
entities.remove(entity1.id);
// Next entity still has ID 1
const entity2 = myFactory.create();
console.log(entity2.id); // 1 (not 0)
// removeAll resets counter
entities.removeAll();
const entity3 = myFactory.create();
console.log(entity3.id); // 0 (reset)Properties from props are initialized to null if not in default or create(options):
const myFactory = factory.create({
name: 'entity',
props: ['a', 'b', 'c'],
default: { a: 1 }
});
const entity = myFactory.create({ b: 2 });
console.log(entity.a); // 1 (from default)
console.log(entity.b); // 2 (from create options)
console.log(entity.c); // null (not specified)Gotcha: If you expect properties to have a specific default value, include them in default:
// ✗ Unexpected null
const factory1 = factory.create({
name: 'entity',
props: ['count']
});
const entity1 = factory1.create();
console.log(entity1.count + 1); // NaN (null + 1)
// ✓ Expected default
const factory2 = factory.create({
name: 'entity',
props: ['count'],
default: { count: 0 }
});
const entity2 = factory2.create();
console.log(entity2.count + 1); // 1The type property is set during creation. Changing it doesn't affect container storage:
const myFactory = factory.create({ name: 'entity' });
const entity = myFactory.create();
console.log(entity.type); // 'entity'
entity.type = 'different';
console.log(entity.type); // 'different' (changed)
// But this doesn't change how it's stored
// The entity is still stored as type 'entity'Best practice: Don't modify the type property. Treat it as read-only.
Method names cannot conflict with existing entity properties:
const myFactory = factory.create({
name: 'entity',
props: ['id', 'type']
});
// ✗ Cannot register method named 'id' or 'type'
factory.registerMethod('id', function() {
return this.id;
}); // Error: conflicts with entity propertyEntities don't use prototype inheritance. Methods are attached directly to each entity:
const myFactory = factory.create({
name: 'entity',
methods: ['method1']
});
const entity1 = myFactory.create();
const entity2 = myFactory.create();
console.log(entity1.method1 === entity2.method1); // false
// Each entity has its own method referenceImplication: While this prevents prototype chain issues, it means each entity has its own copy of each method. For thousands of entities, this can use more memory than prototype-based approaches.
Best practice: Store entity IDs, not direct references:
const { factory, entities } = require('ecos');
const myFactory = factory.create({
name: 'entity',
props: ['data']
});
const parent = myFactory.create({ data: 'parent' });
const child = myFactory.create({ data: 'child' });
// ✗ Not recommended: direct reference
parent.child = child;
// ✓ Recommended: store ID
parent.childId = child.id;
// Later, access via container
const retrievedChild = entities.get(parent.childId);Why: Direct references prevent proper garbage collection when entities are removed from the container.
ECOS doesn't provide methods to query entities by type, property values, etc:
// ✗ Not available
// entities.findByType('player')
// entities.where({ health: 100 })
// entities.filter(entity => entity.active)Solution: Implement your own query system if needed:
class EntityManager {
constructor() {
this.entitiesByType = new Map();
}
create(factory, options) {
const entity = factory.create(options);
// Index by type
if (!this.entitiesByType.has(entity.type)) {
this.entitiesByType.set(entity.type, []);
}
this.entitiesByType.get(entity.type).push(entity.id);
return entity;
}
getByType(type) {
const ids = this.entitiesByType.get(type) || [];
return ids.map(id => entities.get(id)).filter(Boolean);
}
}The factory and entities modules maintain global state:
const { factory, entities } = require('ecos');
// Registration persists across test runs
factory.registerMethod('testMethod', function() {});
// Entities persist across test runs
const myFactory = factory.create({ name: 'test' });
myFactory.create();
console.log(entities.size()); // 1Solution: Reset state between tests:
beforeEach(() => {
entities.removeAll(); // Clear entities
factory.reset(); // Clear registrations
});Entity IDs start from 0, making them predictable in tests:
const { factory, entities } = require('ecos');
beforeEach(() => {
entities.removeAll(); // Reset IDs to 0
});
test('entity creation', () => {
const myFactory = factory.create({ name: 'test' });
const entity = myFactory.create();
expect(entity.id).toBe(0); // Predictable
});Errors from ECOS don't always provide detailed context:
try {
const factory1 = factory.create({
name: 'entity',
methods: ['nonExistent']
});
} catch (error) {
console.log(error.message);
// May not tell you which method was not found
}Best practice: Validate configuration before creating factories:
function validateFactory(config) {
if (config.methods) {
config.methods.forEach(methodName => {
// Check if method exists
// Throw descriptive error if not
});
}
return factory.create(config);
}ECOS doesn't warn about unused configuration:
// No error or warning for typo
const myFactory = factory.create({
name: 'entity',
porps: ['value'] // Typo: should be 'props'
});
const entity = myFactory.create();
// entity doesn't have 'value' property, no warningBest practice: Use TypeScript or validate configurations to catch typos.