Skip to content

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, and PlayerInput components.
  • 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.

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

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

Components

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

typescript
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

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

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 changes

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

typescript
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

typescript
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

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

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;
    }
  }
}

Change Detection Filters

Queries can filter entities based on component changes within the current tick:

  • Added<T> -- Component was added this tick
  • Changed<T> -- Component was modified this tick (via world.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

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

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

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

typescript
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 initialization
  • CoreSchedule.Update -- Runs every frame
  • CoreSchedule.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:

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

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

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

typescript
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); // 1

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

typescript
const config = world.getResource(GameConfig);
config.difficulty = 2; // This persists -- it's the real object

Built-in Time Resources

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

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

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

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

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

typescript
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, Attacking tags).

Common Patterns

Entity State via Tags

Use tag components to represent entity state instead of enum fields:

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

typescript
const FollowTarget = defineComponent('FollowTarget', {
  entity: 'u32',
});

For hierarchical relationships (parent-child), use the built-in hierarchy support:

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

typescript
import { defineRelation } from '@web-engine-dev/ecs';

const ChildOf = defineRelation('ChildOf');

// Set a relation
world.addRelation(child, ChildOf, parent);

Next Steps

  • Rendering -- Learn how the renderer integrates with the ECS
  • Physics -- See how physics systems use ECS components
  • Scenes -- Understand how ECS worlds are saved and loaded

Proprietary software. All rights reserved.