Entity Component System
The ECS (@web-engine-dev/ecs) is the central coordination layer of Web Engine Dev. It provides a high-performance, data-oriented architecture for composing game logic from small, reusable pieces.
Why ECS?
Traditional object-oriented game architectures use deep inheritance hierarchies (e.g., GameObject > Actor > Character > Player). This leads to rigid designs where changing behavior requires restructuring entire class trees.
ECS takes a different approach:
- Entities are lightweight identifiers (just numbers) with no behavior.
- Components are plain data attached to entities. No methods, no inheritance.
- Systems are functions that operate on entities with specific component combinations.
This separation gives you:
- Cache-friendly memory layout -- Components are stored in contiguous arrays (Struct of Arrays), enabling fast linear iteration.
- Composition over inheritance -- Build entities by mixing and matching components. A "player" is just an entity with
Position,Velocity,Health, andPlayerInputcomponents. - Parallelism -- Systems declare what data they read and write. Non-conflicting systems can run in parallel automatically.
- Decoupled logic -- Systems don't know about each other. Add or remove gameplay features by adding or removing systems.
Worlds
A World is the top-level container that holds all entities, components, resources, and events.
import { createWorld } from '@web-engine-dev/ecs';
const world = createWorld();You typically create a single world for your game, though multiple worlds are supported for testing or editor scenarios.
Entities
Entities are lightweight numeric identifiers. They have no data or behavior on their own -- they are handles you use to attach components.
// Spawn a new entity
const entity = world.spawn();
// Check if an entity is still alive
world.isAlive(entity); // true
// Destroy an entity and all its components
world.despawn(entity);
world.isAlive(entity); // falseComponents
Components are typed data containers defined with a schema. The schema specifies the fields and their types, which determines how data is stored in memory.
Defining Components
import { defineComponent, defineTag } from '@web-engine-dev/ecs';
// Data component with typed fields
const Position = defineComponent('Position', {
x: 'f32',
y: 'f32',
z: 'f32',
});
const Health = defineComponent('Health', {
current: 'f32',
max: 'f32',
});
// Tag component (zero-size marker, no data)
const IsPlayer = defineTag('IsPlayer');Available schema field types: i8, i16, i32, u8, u16, u32, f32, f64, bool
Inserting and Getting Component Data
// Add a component to an entity
world.insert(entity, Position, { x: 0, y: 5, z: 0 });
world.insert(entity, Health, { current: 100, max: 100 });
// Read component data
const pos = world.get(entity, Position);
console.log(pos); // { x: 0, y: 5, z: 0 }
// Check if an entity has a component
world.has(entity, Position); // true
// Remove a component
world.remove(entity, Health);CRITICAL: world.get() Returns a Copy
world.get(entity, Component) returns a copy of the data, not a reference. The ECS uses Struct-of-Arrays (SoA) columnar storage internally -- calling get() creates a fresh object from the column values.
Mutating the returned object does NOT update the underlying storage. You must call world.insert() to persist changes.
// BUG: Changes are silently lost
const pos = world.get(entity, Position);
pos.x += 10; // Modifies a local copy only!
// Next frame, world.get() returns the original values
// CORRECT: Write back after modifying
const pos = world.get(entity, Position);
pos.x += 10;
world.insert(entity, Position, pos); // Persist changesThis is the most common source of bugs when working with the ECS.
Sparse Components
Regular components cause archetype transitions when added or removed (explained in Archetype Storage below). For components that are frequently toggled on and off, use sparse storage to avoid the transition cost:
const Cooldown = defineComponent('Cooldown', { remaining: 'f32' }, {
sparse: true,
});Sparse components have O(1) add/remove without archetype transitions, but iterate slightly slower than regular components.
Queries
Queries are the primary way to find and iterate over entities. A query matches all entities that have a specific combination of components.
Building Queries
import { queryBuilder } from '@web-engine-dev/ecs';
const movingQuery = queryBuilder()
.with(Position, Velocity) // Required components
.without(Frozen) // Exclude entities with this component
.withOptional(Acceleration) // May or may not be present
.read(Velocity) // Declare read-only access
.write(Position) // Declare write access
.build();Iterating Query Results
// Iterate over matching entities
for (const { entity, components: [pos, vel] } of world.run(movingQuery)) {
// pos and vel are copies of the component data
pos.x += vel.x * dt;
pos.y += vel.y * dt;
world.insert(entity, Position, pos);
}For high-performance iteration, access the underlying typed arrays directly:
for (const { table, count, getColumn } of world.run(movingQuery).tables()) {
const posX = getColumn(Position, 'x');
const velX = getColumn(Velocity, 'x');
if (posX && velX) {
for (let i = 0; i < count; i++) {
posX.data[i] += velX.data[i] * dt;
}
}
}Change Detection Filters
Queries can filter entities based on component changes within the current tick:
Added<T>-- Component was added this tickChanged<T>-- Component was modified this tick (viaworld.insert())Removed<T>-- Component was removed this tick
These filters are useful for systems that only need to process entities when their data changes, avoiding redundant work.
Systems
Systems are functions that contain your game logic. They operate on the world through queries, resources, and commands.
Defining Systems
import { defineSystem } from '@web-engine-dev/ecs';
const MovementSystem = defineSystem('Movement', (world, commands) => {
const dt = world.getResource(ScaledDeltaTime) ?? 1 / 60;
for (const { entity, components: [pos, vel] } of world.run(movingQuery)) {
pos.x += vel.x * dt;
pos.y += vel.y * dt;
world.insert(entity, Position, pos);
}
});You can also use the builder API for more control:
import { systemBuilder } from '@web-engine-dev/ecs';
const MovementSystem = systemBuilder()
.name('Movement')
.query('moving', (q) => q.with(Position, Velocity).write(Position))
.build((world, commands, { moving }) => {
// System logic
});Run Conditions
Systems can be conditionally executed using run conditions:
import {
inState,
resourceExists,
queryNotEmpty,
onTimer,
and,
} from '@web-engine-dev/ecs';
// Only run when the game is in the "Playing" state
scheduler.addSystem(CoreSchedule.Update, MovementSystem, {
runIf: inState('Playing'),
});
// Run every 500ms
scheduler.addSystem(CoreSchedule.Update, CleanupSystem, {
runIf: onTimer(500),
});
// Combine conditions
scheduler.addSystem(CoreSchedule.Update, AISystem, {
runIf: and(inState('Playing'), queryNotEmpty(enemyQuery)),
});Available run conditions include: inState, notInState, resourceExists, resourceEquals, resourceSatisfies, queryNotEmpty, queryEmpty, onTimer, runOnce, onFirstTick, always, never, and, or, not, hasMinEntities, hasMaxEntities.
Scheduling
The Scheduler manages system execution order using a dependency-based DAG (directed acyclic graph). Systems that don't conflict can run in parallel.
import { Scheduler, CoreSchedule, CommonSets } from '@web-engine-dev/ecs';
const scheduler = new Scheduler();
// Add systems to the Update schedule
scheduler.addSystem(CoreSchedule.Update, InputSystem);
scheduler.addSystem(CoreSchedule.Update, MovementSystem);
scheduler.addSystem(CoreSchedule.Update, CollisionSystem);
scheduler.addSystem(CoreSchedule.Update, RenderSystem);
// Run one frame
scheduler.run(CoreSchedule.Update, world);Core Schedules
The ECS provides built-in schedule identifiers:
CoreSchedule.Startup-- Runs once at initializationCoreSchedule.Update-- Runs every frameCoreSchedule.FixedUpdate-- Runs at a fixed timestep (physics, netcode)CoreSchedule.PostUpdate-- Runs after Update (cleanup, preparation for next frame)
System Sets
Group systems into logical sets for ordering:
import { defineSystemSet, CommonSets } from '@web-engine-dev/ecs';
const PhysicsSet = defineSystemSet('Physics');
const RenderingSet = defineSystemSet('Rendering');
// Add systems to sets, then order the sets
scheduler.addSystem(CoreSchedule.Update, MovementSystem, {
inSet: PhysicsSet,
});
scheduler.addSystem(CoreSchedule.Update, RenderSystem, {
inSet: RenderingSet,
after: PhysicsSet,
});Commands
Never mutate the world directly while iterating entities -- this can invalidate iterators and corrupt state. Use commands to queue mutations that are applied after systems complete.
const CombatSystem = defineSystem('Combat', (world, commands) => {
for (const { entity, components: [health] } of world.run(healthQuery)) {
if (health.current <= 0) {
// Queue entity destruction -- safe during iteration
commands.despawn(entity);
}
}
// Spawn a new entity
commands.spawn()
.insert(Position, { x: 0, y: 0, z: 0 })
.insert(Velocity, { x: 1, y: 0, z: 0 });
// Modify an existing entity
commands.entity(someEntity)
.insert(Health, { current: 100, max: 100 })
.remove(Invincible);
});Commands are automatically applied at sync points between system groups. You can also apply them manually:
commands.getQueue().apply(world);Resources
Resources are globally-unique singletons -- data that exists outside of entities. Use them for game-wide state like time, input, configuration, or render settings.
import { defineResource } from '@web-engine-dev/ecs';
// Define a resource type
const GameConfig = defineResource<{
difficulty: number;
maxEnemies: number;
}>('GameConfig');
// Insert a resource into the world
world.insertResource(GameConfig, { difficulty: 1, maxEnemies: 50 });
// Access a resource
const config = world.getResource(GameConfig);
console.log(config?.difficulty); // 1Resources are References
Unlike world.get() for components (which returns a copy), world.getResource() returns a direct reference. Mutations to the returned object persist automatically without needing to call insertResource() again.
const config = world.getResource(GameConfig);
config.difficulty = 2; // This persists -- it's the real objectBuilt-in Time Resources
The ECS provides pre-defined resource descriptors for time integration:
import {
ScaledDeltaTime,
ElapsedTime,
TimeScale,
updateTimeResources,
} from '@web-engine-dev/ecs';
// In your game loop, sync time resources after updating the time manager:
updateTimeResources(world, timeManager);
// Systems access time via resources:
const dt = world.getResource(ScaledDeltaTime);
const elapsed = world.getResource(ElapsedTime);Events
Events enable decoupled communication between systems. They use a double-buffered queue: events sent in frame N become readable in frame N+1, ensuring deterministic behavior regardless of system execution order.
import { defineEvent } from '@web-engine-dev/ecs';
// Define an event type
const DamageEvent = defineEvent<{
target: number;
amount: number;
source: string;
}>('DamageEvent');
// Send events (in one system)
const writer = world.eventWriter(DamageEvent);
writer.send({ target: enemyEntity, amount: 25, source: 'sword' });
// Read events in the next frame (in another system)
const reader = world.eventReader(DamageEvent);
for (const event of reader.read()) {
console.log(`${event.source} dealt ${event.amount} damage`);
}Events can also have validation and configurable lifetimes:
const ScoreEvent = defineEvent<{ points: number }>('ScoreEvent', {
validate: (data) => data.points > 0,
lifetime: 4, // Readable for 4 frames instead of default 2
});Observers
Observers provide reactive callbacks for component lifecycle events. Unlike events (which are polled), observers fire immediately when the triggering action occurs.
import { OnAdd, OnRemove, OnChange } from '@web-engine-dev/ecs';
// React when Health is added to an entity
world.observe(OnAdd(Health), (world, entity) => {
console.log(`Entity ${entity} gained health`);
});
// React when Health changes
world.observe(OnChange(Health), (world, entity, oldValue, newValue) => {
if (newValue.current <= 0) {
console.log(`Entity ${entity} died`);
}
});
// React when Health is removed
world.observe(OnRemove(Health), (world, entity) => {
console.log(`Entity ${entity} lost health component`);
});You can also define custom triggers:
import { defineTrigger } from '@web-engine-dev/ecs';
const LevelUp = defineTrigger<{ level: number }>('LevelUp');
world.observe(LevelUp, (world, entity, data) => {
console.log(`Entity ${entity} reached level ${data.level}`);
});Archetype Storage
Understanding archetype storage helps you write more performant code.
Entities with the same set of components share an archetype. All entities in an archetype are stored together in contiguous arrays:
Archetype [Position, Velocity] Archetype [Position, Velocity, Health]
+--------+----------+----------+ +--------+----------+----------+--------+
| Entity | Position | Velocity | | Entity | Position | Velocity | Health |
+--------+----------+----------+ +--------+----------+----------+--------+
| E1 | (0,5,0) | (1,0,0) | | E3 | (3,0,0) | (0,1,0) | 100 |
| E2 | (2,3,0) | (0,2,0) | | E4 | (7,1,0) | (1,1,0) | 75 |
+--------+----------+----------+ +--------+----------+----------+--------+When you add or remove a component, the entity moves to a different archetype. This transition copies data but is fast due to cached "edge" lookups between archetypes.
Performance implications:
- Iterating entities within an archetype is very fast (linear memory access).
- Adding/removing components causes archetype transitions (O(components) copy).
- Frequently toggling components? Use sparse components instead.
- Tags (zero-size components) create distinct archetypes at zero memory cost -- use them for state machines (e.g.,
Idle,Walking,Attackingtags).
Common Patterns
Entity State via Tags
Use tag components to represent entity state instead of enum fields:
const Idle = defineTag('Idle');
const Walking = defineTag('Walking');
const Attacking = defineTag('Attacking');
// Transition states by swapping tags
world.remove(entity, Idle);
world.insert(entity, Walking, {});Entity References
Store entity IDs in components to create relationships:
const FollowTarget = defineComponent('FollowTarget', {
entity: 'u32',
});For hierarchical relationships (parent-child), use the built-in hierarchy support:
// Set parent-child relationship
world.setParent(childEntity, parentEntity);
// Query the hierarchy
const parent = world.parent(entity);
const children = world.children(entity);Relations
The ECS supports entity-to-entity relationships natively:
import { defineRelation } from '@web-engine-dev/ecs';
const ChildOf = defineRelation('ChildOf');
// Set a relation
world.addRelation(child, ChildOf, parent);