Skip to content

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

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

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

typescript
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

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

typescript
// 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 storage

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

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

typescript
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():

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

typescript
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 tick
  • Changed(Component) -- Component was modified this tick (via world.insert())
  • With(Component) -- Entity has the component (static filter)
  • Without(Component) -- Entity does not have the component
  • And(filter1, filter2) -- Both filters must match
  • Or(filter1, filter2) -- Either filter must match
  • Not(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:

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

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

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

SchedulePurpose
CoreSchedule.StartupRuns once at initialization (asset loading, entity spawning)
CoreSchedule.PreUpdateRuns before the main update (input processing, time sync)
CoreSchedule.UpdateMain update phase (game logic, AI, movement)
CoreSchedule.PostUpdateRuns after update (cleanup, render preparation)
CoreSchedule.FixedUpdateFixed timestep (physics, netcode, deterministic simulation)

System Sets

Group systems into logical sets for ordering:

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

typescript
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.

typescript
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

typescript
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

typescript
commands.entity(someEntity)
  .insert(Health, { current: 100, max: 100 })
  .remove(Invincible);

Despawning Entities

typescript
commands.despawn(deadEntity);

Applying Commands

Commands are automatically applied at sync points between system groups. You can also apply them manually:

typescript
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

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

typescript
const config = world.getResource(GameConfig);
config.difficulty = 2; // This persists -- it's the real object
// No need to call insertResource() again

Built-in Time Resources

The ECS provides pre-defined resource descriptors for time integration:

typescript
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

typescript
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

typescript
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

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

typescript
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

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

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

typescript
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

typescript
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

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

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

typescript
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

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

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

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

Proprietary software. All rights reserved.