ECS

Entities & components

How entities are identified, how components are defined and registered, and how to build and query entities using the high-level Entity builder and the low-level EntityComponentDataset.

An entity is an integer ID plus a generation counter — nothing more. The EntityComponentDataset recycles IDs when entities are removed; the generation counter makes recycled IDs distinguishable from stale references without any extra tracking.

Defining a component

A component is a plain JavaScript class. The only requirement is a static typeName string, which the dataset uses for name-based lookup and serialisation:

export class Velocity {
    constructor() {
        this.x = 0;
        this.y = 0;
        this.z = 0;
    }
}

Velocity.typeName = "Velocity";

Components have no built-in lifecycle hooks and no methods that reference the entity they belong to. Logic lives in systems.

One component per type per entity

A dataset enforces the rule: each entity can hold at most one instance of any given component type. Attempting to add a second instance of the same type to the same entity throws. This constraint is intentional — it makes component lookup O(1) and keeps the data layout flat.

Building an entity — the high-level API

Entity is a chainable builder that accumulates components and then inserts everything into a dataset in one call:

import Entity from "@woosh/meep-engine/src/engine/ecs/Entity.js";
import { SerializationMetadata } from "@woosh/meep-engine/src/engine/ecs/components/SerializationMetadata.js";
import { Transform } from "@woosh/meep-engine/src/engine/ecs/transform/Transform.js";

const transform = new Transform();
transform.position.set(10, 0, 20);

const entityId = new Entity()
    .add(transform)
    .add(SerializationMetadata.Transient)
    .build(ecd);           // returns the numeric entity ID

add() returns this for chaining. If the entity is already built when you call add(), the component is forwarded directly to the dataset. entity.destroy() removes the entity from the dataset and clears the builder so it can be rebuilt later.

Entity also exposes:

MethodDescription
hasComponent(klass)Returns true if a component of that type is present
getComponent(klass)Returns the instance, or null if absent
getComponentSafe(klass)Same, but throws if absent
removeComponent(klass)Removes and returns the instance (also removes from dataset if built)
isBuilttrue after build(), false after destroy()

Reading an existing entity into a builder

If you have a numeric entity ID and a dataset, Entity.readFromDataset(id, ecd) reconstructs an Entity builder from the live components, so you can inspect or modify them using the same API.

The low-level API — EntityComponentDataset

EntityComponentDataset is the actual storage. The Entity builder calls into it; you can also use it directly when you need fine-grained control.

import { EntityComponentDataset } from "@woosh/meep-engine/src/engine/ecs/EntityComponentDataset.js";

const ecd = new EntityComponentDataset();

// Register the component types the dataset will hold
ecd.registerComponentType(Transform);
ecd.registerComponentType(Velocity);

// Create an entity
const id = ecd.createEntity();

// Add components to it
ecd.addComponentToEntity(id, new Transform());
ecd.addComponentToEntity(id, new Velocity());

// Read a component back
const t = ecd.getComponent(id, Transform);  // returns the instance or undefined

// Remove a component
ecd.removeComponentFromEntity(id, Velocity);

// Destroy the entity entirely
ecd.removeEntity(id);

EntityManager calls registerManyComponentTypes automatically from the component lists declared on each system’s dependencies and components_used, so you rarely need to register types by hand.

Key dataset methods:

MethodDescription
createEntity()Allocates a new ID (reuses freed slots)
entityExists(id)Tests whether an ID is live
getEntityGeneration(id)Returns the generation counter for a live entity
addComponentToEntity(id, instance)Attaches a component; type must be registered
getComponent(id, klass)Returns the instance or undefined
getComponentSafe(id, klass)Same, throws on missing
hasComponent(id, klass)Boolean presence check
removeComponentFromEntity(id, klass)Detaches a component
removeEntity(id)Removes all components, fires lifecycle events, frees the ID
getEntityCount()Current live entity count

Component observers and lifecycle signals

An EntityObserver fires a callback when an entity first acquires a complete set of specified components, and again when that set breaks. Systems use this internally; you can also use it directly:

import { EntityObserver } from "@woosh/meep-engine/src/engine/ecs/EntityObserver.js";

const observer = new EntityObserver(
    [Transform, Velocity],                          // watch for this tuple
    (transform, velocity, entity) => { /* linked */ },
    (transform, velocity, entity) => { /* unlinked */ }
);

ecd.addObserver(observer, true);  // true = fire immediately for existing matches
// ...
ecd.removeObserver(observer, true);  // true = fire broken-callbacks on removal

For per-entity events, the dataset also dispatches string-named events on individual entities. The built-in event names are in EventType:

import { EventType } from "@woosh/meep-engine/src/engine/ecs/EventType.js";

// Listen for a component being added to a specific entity
ecd.addEntityEventListener(entityId, EventType.ComponentAdded, (event) => {
    console.log("added", event.klass.typeName, event.instance);
});

EventType.ComponentAdded, EventType.ComponentRemoved, and EventType.EntityRemoved are the three built-in events.

Where to go next