tessl install tessl/npm-ecos@0.2.0Entity Component System for JavaScript that enables component-based entity creation and management using a factory pattern
Complete API documentation for the extenders module, which provides constants for extender types and detailed guidance on using extenders.
const { extenders } = require('ecos');The extenders module exports constants that define extender types. Extenders customize entity creation by:
Extenders execute during entity creation, before properties from default or create(options) are assigned.
Constant for creating extenders that add custom getters and setters to entity properties.
/**
* Extender type for creating properties with custom getters and setters
* @type {string}
*/
extenders.GETSET; // Value: 'getterssetter''getterssetter' (string)
Use this constant when registering extenders that define property accessors. GETSET extenders create properties with custom get/set behavior.
{
type: extenders.GETSET,
name: string, // Property name to create
get: function() {}, // Optional getter function
set: function(value) {} // Optional setter function
}Structure Fields:
type - Must be extenders.GETSETname - The property name that will be created on entitiesget - Optional function that returns the property value. Called with entity as this context.set - Optional function that sets the property value. Called with entity as this context and new value as first argument.Getter/Setter Rules:
get is omitted, the property will be write-only (setter only)set is omitted, the property will be read-only (getter only)this contextconst { factory, extenders } = require('ecos');
// Read-only computed property
factory.registerExtender('fullName', {
type: extenders.GETSET,
name: 'fullName',
get: function() {
return `${this.firstName} ${this.lastName}`;
}
});
const personFactory = factory.create({
name: 'person',
props: ['firstName', 'lastName'],
extend: ['fullName']
});
const person = personFactory.create({ firstName: 'John', lastName: 'Doe' });
console.log(person.fullName); // "John Doe"
// Read-only - setter not defined
person.fullName = 'Jane Smith'; // Has no effect
console.log(person.fullName); // Still "John Doe"// Property with validation
factory.registerExtender('validatedLevel', {
type: extenders.GETSET,
name: 'level',
get: function() {
return this._level || 1;
},
set: function(value) {
// Clamp level between 1 and 100
this._level = Math.max(1, Math.min(100, value));
}
});
const characterFactory = factory.create({
name: 'character',
props: ['name'],
extend: ['validatedLevel']
});
const char = characterFactory.create({ name: 'Hero' });
console.log(char.level); // 1 (default)
char.level = 50;
console.log(char.level); // 50
char.level = 200; // Exceeds maximum
console.log(char.level); // 100 (clamped)
char.level = -10; // Below minimum
console.log(char.level); // 1 (clamped)// Temperature conversion
factory.registerExtender('celsius', {
type: extenders.GETSET,
name: 'celsius',
get: function() {
return this._celsius || 0;
},
set: function(value) {
this._celsius = value;
this._fahrenheit = (value * 9/5) + 32;
}
});
factory.registerExtender('fahrenheit', {
type: extenders.GETSET,
name: 'fahrenheit',
get: function() {
return this._fahrenheit || 32;
},
set: function(value) {
this._fahrenheit = value;
this._celsius = (value - 32) * 5/9;
}
});
const weatherFactory = factory.create({
name: 'weather',
extend: ['celsius', 'fahrenheit']
});
const weather = weatherFactory.create();
weather.celsius = 25;
console.log(weather.celsius); // 25
console.log(weather.fahrenheit); // 77
weather.fahrenheit = 32;
console.log(weather.celsius); // 0
console.log(weather.fahrenheit); // 32factory.registerExtender('healthPercent', {
type: extenders.GETSET,
name: 'healthPercent',
get: function() {
if (this.maxHealth === 0) return 0;
return (this.health / this.maxHealth) * 100;
}
});
factory.registerExtender('isAlive', {
type: extenders.GETSET,
name: 'isAlive',
get: function() {
return this.health > 0;
}
});
const lifeFactory = factory.create({
name: 'living',
props: ['health', 'maxHealth'],
default: { health: 100, maxHealth: 100 },
extend: ['healthPercent', 'isAlive']
});
const entity = lifeFactory.create({ health: 75 });
console.log(entity.healthPercent); // 75
console.log(entity.isAlive); // true
entity.health = 0;
console.log(entity.healthPercent); // 0
console.log(entity.isAlive); // falsefactory.registerExtender('trackedValue', {
type: extenders.GETSET,
name: 'value',
get: function() {
return this._value || 0;
},
set: function(newValue) {
const oldValue = this._value || 0;
this._value = newValue;
// Side effect: log changes
if (!this._changes) this._changes = [];
this._changes.push({
timestamp: Date.now(),
oldValue,
newValue
});
}
});
const trackedFactory = factory.create({
name: 'tracked',
extend: ['trackedValue']
});
const entity = trackedFactory.create();
entity.value = 10;
entity.value = 20;
entity.value = 15;
console.log(entity.value); // 15
console.log(entity._changes);
// [
// { timestamp: 1609459200000, oldValue: 0, newValue: 10 },
// { timestamp: 1609459201000, oldValue: 10, newValue: 20 },
// { timestamp: 1609459202000, oldValue: 20, newValue: 15 }
// ]Constant for creating extenders that execute custom initialization logic during entity creation.
/**
* Extender type for running custom functions during entity creation
* @type {string}
*/
extenders.FUNCTION; // Value: 'function''function' (string)
Use this constant when registering extenders that need to run initialization code. FUNCTION extenders execute during entity creation and can modify the entity object.
{
type: extenders.FUNCTION,
handler: function(entity, extender) {} // Required handler function
}Structure Fields:
type - Must be extenders.FUNCTIONhandler - Function that executes during entity creation
entity - The entity being createdextender - The extender configuration objectHandler Context:
default or create(options) are assignedconst { factory, extenders } = require('ecos');
factory.registerExtender('timestamps', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.createdAt = Date.now();
entity.updatedAt = Date.now();
}
});
const entityFactory = factory.create({
name: 'entity',
props: ['name'],
extend: ['timestamps']
});
const entity = entityFactory.create({ name: 'MyEntity' });
console.log(entity);
// {
// type: 'entity',
// name: 'MyEntity',
// id: 0,
// createdAt: 1609459200000,
// updatedAt: 1609459200000
// }factory.registerExtender('uuid', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.uuid = Math.random().toString(36).substr(2, 9);
}
});
const itemFactory = factory.create({
name: 'item',
props: ['name'],
extend: ['uuid']
});
const item1 = itemFactory.create({ name: 'Item1' });
const item2 = itemFactory.create({ name: 'Item2' });
console.log(item1.uuid); // 'a1b2c3d4e'
console.log(item2.uuid); // 'f5g6h7i8j' (different)factory.registerExtender('collections', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.tags = [];
entity.metadata = {};
entity.children = [];
}
});
const nodeFactory = factory.create({
name: 'node',
props: ['value'],
extend: ['collections']
});
const node = nodeFactory.create({ value: 42 });
console.log(node);
// {
// type: 'node',
// value: 42,
// id: 0,
// tags: [],
// metadata: {},
// children: []
// }
// Each entity gets its own arrays/objects
const node2 = nodeFactory.create({ value: 100 });
node.tags.push('tag1');
console.log(node.tags); // ['tag1']
console.log(node2.tags); // [] (separate array)factory.registerExtender('randomize', {
type: extenders.FUNCTION,
min: 1,
max: 100,
handler: function(entity, extender) {
// Access extender configuration via second parameter
const range = extender.max - extender.min;
entity.randomValue = Math.floor(Math.random() * range) + extender.min;
}
});
const randomFactory = factory.create({
name: 'random',
extend: ['randomize']
});
const entity1 = randomFactory.create();
console.log(entity1.randomValue); // Random number between 1 and 100
const entity2 = randomFactory.create();
console.log(entity2.randomValue); // Different random numberfactory.registerExtender('gameStats', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.stats = {
health: 100,
mana: 50,
stamina: 100,
strength: 10,
intelligence: 10,
agility: 10
};
entity.inventory = {
items: [],
maxSize: 20,
gold: 0
};
entity.equipment = {
weapon: null,
armor: null,
helmet: null,
boots: null,
accessory: null
};
}
});
const characterFactory = factory.create({
name: 'character',
props: ['name', 'class'],
extend: ['gameStats']
});
const character = characterFactory.create({ name: 'Hero', class: 'Warrior' });
console.log(character.stats); // { health: 100, mana: 50, ... }
console.log(character.inventory); // { items: [], maxSize: 20, gold: 0 }
console.log(character.equipment); // { weapon: null, armor: null, ... }factory.registerExtender('conditionalInit', {
type: extenders.FUNCTION,
handler: function(entity) {
// Note: entity properties from defaults/options not yet available
// Only properties added by previous extenders are accessible
// Initialize based on entity type
if (entity.type.includes('enemy')) {
entity.isHostile = true;
entity.aggro = 0;
} else if (entity.type.includes('npc')) {
entity.isHostile = false;
entity.dialogue = [];
}
}
});You can use both types of extenders together in the same factory:
const { factory, extenders } = require('ecos');
// FUNCTION extender for initialization
factory.registerExtender('init', {
type: extenders.FUNCTION,
handler: function(entity) {
entity._internalValue = 0;
entity.logs = [];
}
});
// GETSET extender for controlled access
factory.registerExtender('valueAccessor', {
type: extenders.GETSET,
name: 'value',
get: function() {
return this._internalValue;
},
set: function(val) {
this.logs.push({
timestamp: Date.now(),
oldValue: this._internalValue,
newValue: val
});
this._internalValue = val;
}
});
const trackedFactory = factory.create({
name: 'tracked',
extend: ['init', 'valueAccessor']
});
const entity = trackedFactory.create();
entity.value = 10;
entity.value = 20;
entity.value = 15;
console.log(entity.value); // 15
console.log(entity.logs);
// [
// { timestamp: 1609459200000, oldValue: 0, newValue: 10 },
// { timestamp: 1609459201000, oldValue: 10, newValue: 20 },
// { timestamp: 1609459202000, oldValue: 20, newValue: 15 }
// ]When a factory creates an entity, operations occur in this order:
type propertyid via entities.set()presets option)extend option)props array initializeddefault option assignedcreate(options) assignedExtenders execute at steps 3-4, BEFORE properties are assigned in steps 5-7.
This means:
default are NOT available in extender handlerscreate(options) are NOT available in extender handlersWithin each group (preset extenders, factory extenders), extenders execute in array order:
const { factory, extenders } = require('ecos');
factory.registerExtender('first', {
type: extenders.FUNCTION,
handler: function(entity) {
console.log('First extender');
entity.order = ['first'];
}
});
factory.registerExtender('second', {
type: extenders.FUNCTION,
handler: function(entity) {
console.log('Second extender');
entity.order.push('second');
}
});
factory.registerExtender('third', {
type: extenders.FUNCTION,
handler: function(entity) {
console.log('Third extender');
entity.order.push('third');
}
});
factory.registerPreset('presetA', {
extend: ['first']
});
const myFactory = factory.create({
name: 'ordered',
presets: ['presetA'],
extend: ['second', 'third']
});
const entity = myFactory.create();
// Console output:
// First extender
// Second extender
// Third extender
console.log(entity.order); // ['first', 'second', 'third']Properties from default or create(options) are NOT available in extender handlers because extenders execute before properties are assigned.
// ❌ WRONG - baseHealth not yet available
factory.registerExtender('badInit', {
type: extenders.FUNCTION,
handler: function(entity) {
// entity.baseHealth is undefined here!
entity.health = entity.baseHealth || 100;
}
});
const factory1 = factory.create({
name: 'character',
props: ['baseHealth'],
default: { baseHealth: 150 },
extend: ['badInit']
});
const char = factory1.create();
console.log(char.health); // 100 (not 150, because baseHealth was undefined)Solutions:
// ✅ CORRECT - use method called after creation
factory.registerMethod('initHealth', function() {
this.health = this.baseHealth || 100;
});
const factory2 = factory.create({
name: 'character',
props: ['baseHealth'],
default: { baseHealth: 150 },
methods: ['initHealth']
});
const char2 = factory2.create();
char2.initHealth(); // Call after creation
console.log(char2.health); // 150// ✅ CORRECT - initialize with defaults, customize via method
factory.registerExtender('healthInit', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.health = 100; // Default value
}
});
factory.registerMethod('setHealth', function(value) {
this.health = value;
});
const factory3 = factory.create({
name: 'character',
extend: ['healthInit'],
methods: ['setHealth']
});
const char3 = factory3.create();
char3.setHealth(150);
console.log(char3.health); // 150GETSET extenders create property accessors. Be aware of property name conflicts:
// ❌ Potential issue - 'value' exists as both prop and GETSET
factory.registerExtender('valueGetter', {
type: extenders.GETSET,
name: 'value',
get: function() { return this._value; }
});
const factory4 = factory.create({
name: 'item',
props: ['value'], // Conflicts with GETSET 'value'
extend: ['valueGetter']
});
// The GETSET will override the propSolution: Use different property names:
// ✅ CORRECT - separate names
factory.registerExtender('publicValue', {
type: extenders.GETSET,
name: 'value',
get: function() { return this._internalValue; },
set: function(v) { this._internalValue = v; }
});
const factory5 = factory.create({
name: 'item',
props: ['_internalValue'], // Different name
extend: ['publicValue']
});factory.registerExtender('lazyInit', {
type: extenders.GETSET,
name: 'expensiveData',
get: function() {
if (!this._expensiveData) {
console.log('Computing expensive data...');
this._expensiveData = /* expensive computation */;
}
return this._expensiveData;
}
});
const dataFactory = factory.create({
name: 'data',
extend: ['lazyInit']
});
const entity = dataFactory.create();
// No computation yet
console.log(entity.expensiveData); // Logs "Computing expensive data...", then returns result
console.log(entity.expensiveData); // Returns cached result immediatelyfactory.registerExtender('observable', {
type: extenders.GETSET,
name: 'observedValue',
get: function() {
return this._observedValue;
},
set: function(newValue) {
const oldValue = this._observedValue;
this._observedValue = newValue;
// Notify observers
if (this._observers) {
this._observers.forEach(callback => {
callback(newValue, oldValue);
});
}
}
});
factory.registerMethod('observe', function(callback) {
if (!this._observers) this._observers = [];
this._observers.push(callback);
});
const observableFactory = factory.create({
name: 'observable',
extend: ['observable'],
methods: ['observe']
});
const entity = observableFactory.create();
entity.observe((newVal, oldVal) => {
console.log(`Value changed from ${oldVal} to ${newVal}`);
});
entity.observedValue = 10; // Logs "Value changed from undefined to 10"
entity.observedValue = 20; // Logs "Value changed from 10 to 20"factory.registerExtender('cachedHealth', {
type: extenders.GETSET,
name: 'healthPercent',
get: function() {
// Recompute only if values changed
if (this._cachedHealthPercent === undefined ||
this._lastHealth !== this.health ||
this._lastMaxHealth !== this.maxHealth) {
this._cachedHealthPercent = (this.health / this.maxHealth) * 100;
this._lastHealth = this.health;
this._lastMaxHealth = this.maxHealth;
}
return this._cachedHealthPercent;
}
});
const entityFactory = factory.create({
name: 'entity',
props: ['health', 'maxHealth'],
default: { health: 100, maxHealth: 100 },
extend: ['cachedHealth']
});
const entity = entityFactory.create();
console.log(entity.healthPercent); // Computes: 100
console.log(entity.healthPercent); // Returns cached: 100
entity.health = 50;
console.log(entity.healthPercent); // Recomputes: 50const { factory, entities, extenders } = require('ecos');
// Register FUNCTION extender for initialization
factory.registerExtender('gameEntityInit', {
type: extenders.FUNCTION,
handler: function(entity) {
entity.createdAt = Date.now();
entity.uuid = Math.random().toString(36).substr(2, 9);
entity._health = 100;
entity._maxHealth = 100;
}
});
// Register GETSET extenders for controlled access
factory.registerExtender('healthAccessor', {
type: extenders.GETSET,
name: 'health',
get: function() {
return this._health;
},
set: function(value) {
this._health = Math.max(0, Math.min(this._maxHealth, value));
}
});
factory.registerExtender('healthPercentGetter', {
type: extenders.GETSET,
name: 'healthPercent',
get: function() {
return (this._health / this._maxHealth) * 100;
}
});
factory.registerExtender('isAliveGetter', {
type: extenders.GETSET,
name: 'isAlive',
get: function() {
return this._health > 0;
}
});
// Register methods
factory.registerMethod('takeDamage', function(amount) {
this.health -= amount;
console.log(`${this.name} took ${amount} damage. Health: ${this.health}/${this._maxHealth}`);
});
factory.registerMethod('heal', function(amount) {
this.health += amount;
console.log(`${this.name} healed ${amount}. Health: ${this.health}/${this._maxHealth}`);
});
// Create factory
const characterFactory = factory.create({
name: 'character',
props: ['name'],
extend: ['gameEntityInit', 'healthAccessor', 'healthPercentGetter', 'isAliveGetter'],
methods: ['takeDamage', 'heal']
});
// Create entity
const hero = characterFactory.create({ name: 'Hero' });
console.log(hero.healthPercent); // 100
console.log(hero.isAlive); // true
hero.takeDamage(30);
console.log(hero.healthPercent); // 70
hero.heal(20);
console.log(hero.healthPercent); // 90
hero.takeDamage(200); // Clamped to 0
console.log(hero.health); // 0
console.log(hero.isAlive); // false