ECS Guide
This guide covers practical patterns for building games with the Entity Component System (@web-engine-dev/ecs). For conceptual background on what ECS is and why it matters, see Core Concepts: ECS. For full API details, see the ECS package documentation.
Defining Components
Components are plain data containers defined with a typed schema. Each field has a numeric type that determines how data is stored in memory.
Data Components
import { defineComponent } from '@web-engine-dev/ecs';
const Position = defineComponent('Position', {
x: 'f32',
y: 'f32',
z: 'f32',
});
const Health = defineComponent('Health', {
current: 'f32',
max: 'f32',
});
const Damage = defineComponent('Damage', {
amount: 'f32',
isCritical: 'bool',
sourceId: 'u32',
});Available schema field types: i8, i16, i32, u8, u16, u32, f32, f64, bool
Choose the narrowest type that fits your data. Use u8 for small counters, f32 for positions and velocities, u32 for entity references and IDs.
Tag Components
Tags are zero-size marker components with no data. They are used to categorize entities or represent states:
import { defineTag } from '@web-engine-dev/ecs';
const IsPlayer = defineTag('IsPlayer');
const IsEnemy = defineTag('IsEnemy');
// State tags
const Idle = defineTag('Idle');
const Walking = defineTag('Walking');
const Attacking = defineTag('Attacking');Tags create distinct archetypes at zero memory cost, making them ideal for state machines and entity classification.
Sparse Components
Regular components cause archetype transitions when added or removed. For components that are frequently toggled, use sparse storage:
const Cooldown = defineComponent('Cooldown', { remaining: 'f32' }, {
sparse: true,
});
const Stunned = defineComponent('Stunned', { duration: 'f32' }, {
sparse: true,
});Sparse components have O(1) add/remove with no archetype transitions, but iterate slightly slower than regular components. Use them for temporary effects like cooldowns, buffs, and debuffs.
Inserting and Reading Component Data
import { createWorld } from '@web-engine-dev/ecs';
const world = createWorld();
const entity = world.spawn();
// Insert component data
world.insert(entity, Position, { x: 0, y: 5, z: 0 });
world.insert(entity, Health, { current: 100, max: 100 });
world.insert(entity, IsPlayer, {});
// Check if an entity has a component
world.has(entity, Position); // true
// Read component data
const pos = world.get(entity, Position);
console.log(pos); // { x: 0, y: 5, z: 0 }
// Remove a component
world.remove(entity, Health);
// Destroy an entity and all its components
world.despawn(entity);CRITICAL: world.get() Returns a Copy
world.get(entity, Component) returns a copy of the component data, not a reference. The ECS uses Struct-of-Arrays (SoA) columnar storage -- get() creates a fresh object from column values on every call.
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 to storageThis is the single most common source of bugs when working with the ECS. Always world.insert() after modifying data from world.get().
Querying Entities
Queries find and iterate over entities that match a specific combination of components. They are the primary way systems process entities.
Building Queries
Use queryBuilder() to construct queries with a fluent API:
import { queryBuilder } from '@web-engine-dev/ecs';
// Basic query: entities with both Position and Velocity
const movingQuery = queryBuilder()
.with(Position, Velocity)
.build();
// Exclude entities with a specific component
const activeQuery = queryBuilder()
.with(Position, Velocity)
.without(Frozen)
.build();
// Optional components (may or may not be present)
const physicsQuery = queryBuilder()
.with(Position, Velocity)
.withOptional(Acceleration)
.build();
// Declare access for parallel scheduling
const writeQuery = queryBuilder()
.with(Position, Velocity)
.read(Velocity) // read-only access
.write(Position) // writable access
.build();Iterating Query Results
Use world.run(query) to execute a query and iterate over matching entities:
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); // Persist changes!
}Remember: Components Are Copies
Each iteration yields copies of component data. You must call world.insert() to persist any changes back to storage.
High-Performance Table Iteration
For maximum performance, access the underlying typed arrays directly via .tables():
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;
}
}
}This avoids object allocation per entity and operates directly on contiguous memory, giving cache-friendly linear access.
Change Detection Filters
Queries can filter entities based on component changes within the current tick:
import { Added, Changed, Without } from '@web-engine-dev/ecs';
// Only entities where Health was added this tick
const newHealthFilter = Added(Health);
// Only entities where Position changed this tick
const movedFilter = Changed(Position);
// Compose filters into queries
const newEnemyQuery = queryBuilder()
.with(Position, IsEnemy)
.filter(Added(Position))
.build();Available change detection filters:
Added(Component)-- Component was added this tickChanged(Component)-- Component was modified this tick (viaworld.insert())With(Component)-- Entity has the component (static filter)Without(Component)-- Entity does not have the componentAnd(filter1, filter2)-- Both filters must matchOr(filter1, filter2)-- Either filter must matchNot(filter)-- Negates a filter
Systems and Scheduling
Systems contain game logic and operate on the world through queries, resources, and commands.
Defining Systems
The simplest way to define a system is with defineSystem:
import { defineSystem } from '@web-engine-dev/ecs';
const MovementSystem = defineSystem('Movement', (world, dt) => {
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);
}
});The system function receives the World and deltaTime (in seconds) as arguments.
System Builder API
For more control over system configuration, use systemBuilder:
import { systemBuilder } from '@web-engine-dev/ecs';
const MovementSystem = systemBuilder()
.name('Movement')
.query('moving', (q) => q.with(Position, Velocity).write(Position))
.build((world, dt, { moving }) => {
for (const { entity, components: [pos, vel] } of world.run(moving)) {
pos.x += vel.x * dt;
pos.y += vel.y * dt;
world.insert(entity, Position, pos);
}
});Scheduling Systems
The Scheduler manages system execution order. Systems are added to named schedules:
import { Scheduler, CoreSchedule } from '@web-engine-dev/ecs';
const scheduler = new Scheduler();
scheduler.addSystem(CoreSchedule.Update, InputSystem);
scheduler.addSystem(CoreSchedule.Update, MovementSystem);
scheduler.addSystem(CoreSchedule.Update, CollisionSystem);
scheduler.addSystem(CoreSchedule.Update, RenderPrepSystem);
// Run one frame
scheduler.run(CoreSchedule.Update, world);Core Schedules
The ECS provides built-in schedule identifiers for the standard game loop phases:
| Schedule | Purpose |
|---|---|
CoreSchedule.Startup | Runs once at initialization (asset loading, entity spawning) |
CoreSchedule.PreUpdate | Runs before the main update (input processing, time sync) |
CoreSchedule.Update | Main update phase (game logic, AI, movement) |
CoreSchedule.PostUpdate | Runs after update (cleanup, render preparation) |
CoreSchedule.FixedUpdate | Fixed timestep (physics, netcode, deterministic simulation) |
System Sets
Group systems into logical sets for ordering:
import { defineSystemSet } from '@web-engine-dev/ecs';
const PhysicsSet = defineSystemSet('Physics');
const RenderingSet = defineSystemSet('Rendering');
scheduler.addSystem(CoreSchedule.Update, GravitySystem, {
inSet: PhysicsSet,
});
scheduler.addSystem(CoreSchedule.Update, CollisionSystem, {
inSet: PhysicsSet,
});
scheduler.addSystem(CoreSchedule.Update, RenderPrepSystem, {
inSet: RenderingSet,
after: PhysicsSet, // Rendering runs after all physics
});Run Conditions
Systems can be conditionally executed:
import {
inState,
resourceExists,
queryNotEmpty,
onTimer,
runOnce,
and,
or,
not,
} from '@web-engine-dev/ecs';
// Only run when the game is in the "Playing" state
scheduler.addSystem(CoreSchedule.Update, MovementSystem, {
runIf: inState('Playing'),
});
// Run only once
scheduler.addSystem(CoreSchedule.Startup, InitSystem, {
runIf: runOnce(),
});
// Run every 500ms
scheduler.addSystem(CoreSchedule.Update, SpawnSystem, {
runIf: onTimer(500),
});
// Combine conditions with logical operators
scheduler.addSystem(CoreSchedule.Update, AISystem, {
runIf: and(inState('Playing'), queryNotEmpty(enemyQuery)),
});
// Resource-based conditions
scheduler.addSystem(CoreSchedule.Update, NetworkSystem, {
runIf: resourceExists(NetworkConfig),
});Available run conditions: inState, notInState, resourceExists, resourceNotExists, resourceEquals, resourceSatisfies, queryNotEmpty, queryEmpty, onTimer, runOnce, onTick, onFirstTick, always, never, hasMinEntities, hasMaxEntities, and, or, not.
Commands and Deferred Mutations
Never mutate the world directly while iterating entities -- this can invalidate iterators and corrupt state. Commands queue mutations that are applied safely after systems complete.
const CombatSystem = defineSystem('Combat', (world, dt) => {
for (const { entity, components: [health] } of world.run(healthQuery)) {
if (health.current <= 0) {
// Queue entity destruction -- safe during iteration
commands.despawn(entity);
}
}
});Spawning Entities
const SpawnSystem = defineSystem('Spawn', (world, dt) => {
const commands = world.commands();
// Spawn with components
commands.spawn()
.insert(Position, { x: 0, y: 0, z: 0 })
.insert(Velocity, { x: 1, y: 0, z: 0 })
.insert(IsEnemy, {});
});Modifying Existing Entities
commands.entity(someEntity)
.insert(Health, { current: 100, max: 100 })
.remove(Invincible);Despawning Entities
commands.despawn(deadEntity);Applying Commands
Commands are automatically applied at sync points between system groups. You can also apply them manually:
const commands = createCommands();
// ... queue operations ...
commands.getQueue().apply(world);When to Use Commands vs Direct Mutation
Use commands when inside a system that iterates entities. You can safely use world.insert(), world.spawn(), and world.despawn() outside of iteration loops, but commands are always the safest approach inside system logic.
Resources
Resources are globally-unique singletons that exist outside of entities. Use them for game-wide state like time, configuration, input, or render settings.
Defining and Using Resources
import { defineResource } from '@web-engine-dev/ecs';
const GameConfig = defineResource<{
difficulty: number;
maxEnemies: number;
spawnRate: number;
}>('GameConfig', {
default: () => ({ difficulty: 1, maxEnemies: 50, spawnRate: 2.0 }),
});
// Insert a resource
world.insertResource(GameConfig, { difficulty: 1, maxEnemies: 50, spawnRate: 2.0 });
// Access a resource (returns the live reference or undefined)
const config = world.getResource(GameConfig);
if (config) {
console.log(config.difficulty); // 1
}Resources Are References
Unlike world.get() for components (which returns a copy), world.getResource() returns a direct reference to the stored object. Mutations persist automatically:
const config = world.getResource(GameConfig);
config.difficulty = 2; // This persists -- it's the real object
// No need to call insertResource() againBuilt-in Time Resources
The ECS provides pre-defined resource descriptors for time integration:
import {
ScaledDeltaTime,
ElapsedTime,
DeltaTime,
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) ?? 1 / 60;
const elapsed = world.getResource(ElapsedTime) ?? 0;Resources in Systems
const DifficultySystem = defineSystem('Difficulty', (world, dt) => {
const config = world.getResource(GameConfig);
if (!config) return;
const elapsed = world.getResource(ElapsedTime) ?? 0;
// Increase difficulty over time (resource mutation persists)
if (elapsed > 60) {
config.difficulty = 2;
config.maxEnemies = 100;
}
});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.
Defining Events
import { defineEvent } from '@web-engine-dev/ecs';
// Event with data
const DamageEvent = defineEvent<{
target: number;
amount: number;
source: string;
}>('DamageEvent');
// Event with no data (signal)
const GameStarted = defineEvent('GameStarted');
// Event with validation
const ScoreEvent = defineEvent<{ points: number }>('ScoreEvent', {
validate: (data) => data.points > 0,
});
// Event with extended lifetime (readable for 4 frames instead of default 2)
const SlowEvent = defineEvent<{ id: string }>('SlowEvent', {
lifetime: 4,
});Sending Events
const CombatSystem = defineSystem('Combat', (world, dt) => {
const writer = world.eventWriter(DamageEvent);
for (const { entity, components: [attacker] } of world.run(attackerQuery)) {
writer.send({
target: attacker.targetId,
amount: attacker.damage,
source: 'sword',
});
}
});Reading Events
Events sent in the current frame become readable in the next frame:
const HealthSystem = defineSystem('Health', (world, dt) => {
const reader = world.eventReader(DamageEvent);
for (const event of reader.read()) {
const health = world.get(event.target, Health);
if (health) {
health.current -= event.amount;
world.insert(event.target, Health, health);
}
}
});Double-Buffered Pattern
Events are double-buffered to guarantee deterministic behavior. A system that sends an event and a system that reads the same event type can run in any order within the same frame -- the reader will always see events from the previous frame. Call world.updateEvents() (or let the scheduler handle it) at the end of each frame to swap buffers.
Observers
Observers provide reactive callbacks for component lifecycle events. Unlike events (which are polled each frame), observers fire immediately when the triggering action occurs.
Component Lifecycle Observers
import { OnAdd, OnRemove, OnChange, ObserverRegistry, ObserverRunner } from '@web-engine-dev/ecs';
// Create registry and runner
const registry = new ObserverRegistry();
const runner = new ObserverRunner(registry, world);
// React when Health is added to an entity
const unsubscribe = registry.register(OnAdd(Health), (world, entity) => {
console.log(`Entity ${entity} gained health`);
});
// React when Health changes
registry.register(OnChange(Health), (world, entity, oldValue, newValue) => {
if (newValue.current <= 0) {
world.commands().despawn(entity);
}
});
// React when Health is removed
registry.register(OnRemove(Health), (world, entity) => {
console.log(`Entity ${entity} lost health component`);
});
// Unsubscribe when no longer needed
unsubscribe();Custom Triggers
Define your own trigger types for application-specific events:
import { defineTrigger } from '@web-engine-dev/ecs';
interface LevelUpData {
level: number;
}
const LevelUp = defineTrigger<LevelUpData>('LevelUp');
registry.register(LevelUp, (world, entity, data) => {
console.log(`Entity ${entity} reached level ${data.level}!`);
});
// Fire the custom trigger
runner.triggerCustom(entity, LevelUp, { level: 5 });Observer Utilities
The ECS provides convenience helpers for common observer patterns:
import {
observeComponentLifecycle,
observeOnAddOnce,
observeOnChangeWhen,
} from '@web-engine-dev/ecs';
// Observe all lifecycle events for a component at once
observeComponentLifecycle(registry, Health, {
onAdd: (world, entity) => { /* ... */ },
onChange: (world, entity, oldVal, newVal) => { /* ... */ },
onRemove: (world, entity) => { /* ... */ },
});
// One-shot observer (fires once then unsubscribes)
observeOnAddOnce(registry, IsPlayer, (world, entity) => {
console.log('Player spawned');
});
// Conditional observer (only fires when predicate is true)
observeOnChangeWhen(registry, Health,
(world, entity, oldVal, newVal) => newVal.current <= 0,
(world, entity, oldVal, newVal) => {
console.log(`Entity ${entity} died`);
},
);Relations
Relations model entity-to-entity relationships natively within the ECS. They support exclusive (one target per subject), symmetric (bidirectional), and data-carrying relationships.
Defining Relations
import { defineRelation, ChildOf } from '@web-engine-dev/ecs';
// Custom relation with data
const Follows = defineRelation<{ distance: number }>('Follows');
// Symmetric relation (bidirectional)
const Friends = defineRelation('Friends', { symmetric: true });
// Exclusive relation (one target per subject)
const BelongsTo = defineRelation('BelongsTo', { exclusive: true });Using Relations with RelationStorage
import { RelationStorage, setParent, getParent, getChildren } from '@web-engine-dev/ecs';
const storage = new RelationStorage();
// Add relationships
storage.addPair(npc, Follows, player, { distance: 10 });
storage.addPair(entity1, Friends, entity2);
// Query relationships
const targets = storage.getTargets(npc, Follows);
const followers = storage.getSubjects(player, Follows);Hierarchy (Parent-Child)
The ECS provides built-in hierarchy support using the ChildOf relation:
import {
setParent,
removeParent,
getParent,
getChildren,
hasChildren,
getAncestors,
getDescendants,
isAncestorOf,
getRoot,
getDepth,
} from '@web-engine-dev/ecs';
// Set parent-child relationship
setParent(storage, childEntity, parentEntity);
// Query the hierarchy
const parent = getParent(storage, childEntity);
const children = getChildren(storage, parentEntity);
// Traverse ancestors (bottom-up)
for (const ancestor of getAncestors(storage, entity)) {
console.log('Ancestor:', ancestor);
}
// Traverse descendants (top-down, depth-first)
for (const descendant of getDescendants(storage, entity)) {
console.log('Descendant:', descendant);
}
// Hierarchy utilities
const root = getRoot(storage, entity);
const depth = getDepth(storage, entity);
const isChild = isAncestorOf(storage, parentEntity, childEntity);Putting It All Together
Here is a complete example that ties together components, systems, resources, events, and scheduling:
import {
createWorld,
defineComponent,
defineTag,
defineResource,
defineEvent,
defineSystem,
queryBuilder,
Scheduler,
CoreSchedule,
} from '@web-engine-dev/ecs';
// --- Components ---
const Position = defineComponent('Position', { x: 'f32', y: 'f32', z: 'f32' });
const Velocity = defineComponent('Velocity', { x: 'f32', y: 'f32', z: 'f32' });
const Health = defineComponent('Health', { current: 'f32', max: 'f32' });
const IsEnemy = defineTag('IsEnemy');
// --- Resources ---
const GameState = defineResource<{ score: number; wave: number }>('GameState');
// --- Events ---
const EnemyKilled = defineEvent<{ entityId: number; points: number }>('EnemyKilled');
// --- Queries ---
const movingQuery = queryBuilder().with(Position, Velocity).build();
const enemyHealthQuery = queryBuilder().with(Health, IsEnemy).build();
// --- Systems ---
const MovementSystem = defineSystem('Movement', (world, dt) => {
for (const { entity, components: [pos, vel] } of world.run(movingQuery)) {
pos.x += vel.x * dt;
pos.y += vel.y * dt;
pos.z += vel.z * dt;
world.insert(entity, Position, pos);
}
});
const DeathSystem = defineSystem('Death', (world, dt) => {
const writer = world.eventWriter(EnemyKilled);
const commands = world.commands();
for (const { entity, components: [health] } of world.run(enemyHealthQuery)) {
if (health.current <= 0) {
writer.send({ entityId: entity, points: 100 });
commands.despawn(entity);
}
}
});
const ScoreSystem = defineSystem('Score', (world, dt) => {
const reader = world.eventReader(EnemyKilled);
const state = world.getResource(GameState);
if (!state) return;
for (const event of reader.read()) {
state.score += event.points; // Resource mutation persists
}
});
// --- Setup ---
const world = createWorld();
const scheduler = new Scheduler();
world.insertResource(GameState, { score: 0, wave: 1 });
scheduler.addSystem(CoreSchedule.Update, MovementSystem);
scheduler.addSystem(CoreSchedule.Update, DeathSystem);
scheduler.addSystem(CoreSchedule.Update, ScoreSystem);
// Spawn some entities
const enemy = world.spawn();
world.insert(enemy, Position, { x: 10, y: 0, z: 0 });
world.insert(enemy, Velocity, { x: -1, y: 0, z: 0 });
world.insert(enemy, Health, { current: 50, max: 50 });
world.insert(enemy, IsEnemy, {});
// Game loop
function gameLoop() {
scheduler.run(CoreSchedule.Update, world);
requestAnimationFrame(gameLoop);
}
gameLoop();Common Pitfalls
1. Forgetting to Persist Component Changes
// WRONG -- changes are lost
const pos = world.get(entity, Position);
pos.x += 10;
// RIGHT -- persist with insert
const pos = world.get(entity, Position);
pos.x += 10;
world.insert(entity, Position, pos);2. Mutating During Iteration
// WRONG -- can corrupt state or cause RangeError
for (const { entity } of world.run(query)) {
world.despawn(entity); // Mutates during iteration!
}
// RIGHT -- use commands for deferred mutation
for (const { entity } of world.run(query)) {
commands.despawn(entity); // Queued, applied later
}3. Confusing Resources and Components
// Resources: getResource() returns a REFERENCE -- mutations persist
const config = world.getResource(GameConfig);
config.difficulty = 2; // Works!
// Components: get() returns a COPY -- mutations are lost
const pos = world.get(entity, Position);
pos.x = 5; // Lost without world.insert()!Next Steps
- Rendering Guide -- Learn how the renderer integrates with ECS
- ECS Package Documentation -- Full API reference
- Core Concepts: ECS -- Conceptual overview of archetype storage