or run

tessl search
Log in

Version

Workspace
tessl
Visibility
Public
Created
Last updated
Describes
npmpkg:npm/ecos@0.2.x

docs

index.md
tile.json

tessl/npm-ecos

tessl install tessl/npm-ecos@0.2.0

Entity Component System for JavaScript that enables component-based entity creation and management using a factory pattern

extenders-api.mddocs/api-reference/

Extenders API Reference

Complete API documentation for the extenders module, which provides constants for extender types and detailed guidance on using extenders.

Module Import

const { extenders } = require('ecos');

Overview

The extenders module exports constants that define extender types. Extenders customize entity creation by:

  • Adding property accessors (getters/setters) using GETSET type
  • Running initialization functions using FUNCTION type

Extenders execute during entity creation, before properties from default or create(options) are assigned.

extenders.GETSET

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'

Value

'getterssetter' (string)

Usage

Use this constant when registering extenders that define property accessors. GETSET extenders create properties with custom get/set behavior.

Extender Structure

{
    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.GETSET
  • name - The property name that will be created on entities
  • get - 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:

  • If get is omitted, the property will be write-only (setter only)
  • If set is omitted, the property will be read-only (getter only)
  • Both can be provided for read-write properties
  • Functions execute with the entity as this context

GETSET Examples

Example: Computed Property (Getter Only)

const { 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"

Example: Validated Property (Getter and Setter)

// 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)

Example: Dependent Properties

// 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); // 32

Example: Computed Property with Multiple Dependencies

factory.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);       // false

Example: Property with Side Effects

factory.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 }
// ]

extenders.FUNCTION

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'

Value

'function' (string)

Usage

Use this constant when registering extenders that need to run initialization code. FUNCTION extenders execute during entity creation and can modify the entity object.

Extender Structure

{
    type: extenders.FUNCTION,
    handler: function(entity, extender) {} // Required handler function
}

Structure Fields:

  • type - Must be extenders.FUNCTION
  • handler - Function that executes during entity creation
    • First parameter: entity - The entity being created
    • Second parameter: extender - The extender configuration object
    • Can modify entity directly
    • No return value expected

Handler Context:

  • Called during entity creation
  • Executes BEFORE properties from default or create(options) are assigned
  • Can add new properties or initialize complex structures
  • Has access to extender configuration via second parameter

FUNCTION Examples

Example: Add Timestamps

const { 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
// }

Example: Generate Unique Identifier

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)

Example: Initialize Collections

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)

Example: Extender with Configuration

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 number

Example: Initialize Complex Structures

factory.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, ... }

Example: Conditional Initialization

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 = [];
        }
    }
});

Combining GETSET and FUNCTION Extenders

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 }
// ]

Extender Execution Order

When a factory creates an entity, operations occur in this order:

  1. Entity object created with type property
  2. Entity assigned an id via entities.set()
  3. Preset extenders applied (from presets option)
  4. Factory extenders applied (from extend option)
  5. Properties from props array initialized
  6. Properties from default option assigned
  7. Properties from create(options) assigned
  8. Methods attached

Critical Timing

Extenders execute at steps 3-4, BEFORE properties are assigned in steps 5-7.

This means:

  • Properties from default are NOT available in extender handlers
  • Properties from create(options) are NOT available in extender handlers
  • Only properties added by previous extenders are available

Execution Order Within Extenders

Within 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']

Important Constraints

⚠️ Properties Not Available in Extenders

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:

  1. Use methods instead of extenders for property-dependent initialization:
// ✅ 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
  1. Initialize with fixed values in extender, customize later:
// ✅ 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); // 150

⚠️ GETSET Properties and Entity Properties

GETSET 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 prop

Solution: 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']
});

Advanced Patterns

Pattern: Lazy Initialization

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 immediately

Pattern: Observable Properties

factory.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"

Pattern: Cached Computed Properties

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: 50

Complete Example

const { 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