ECS Architecture

Understanding the Entity-Component-System pattern that powers Web Engine. Learn why ECS enables high-performance game development.

Web Engine is built on the Entity-Component-System (ECS) architecture, a data-oriented design pattern that separates data (Components) from logic (Systems), enabling optimal performance and flexibility.

Why ECS?#

Traditional object-oriented game architectures often suffer from:

  • Deep inheritance hierarchies that become rigid and hard to modify.
  • Cache misses due to scattered memory access patterns.
  • Tight coupling between behaviors and data.
  • Garbage collection pressure from frequent object allocations.

ECS solves these problems by treating game objects as simple IDs (Entities), storing data in flat, typed arrays (Components), and processing them in batches (Systems).

The Three Pillars#

Entities

Simple integer IDs that represent game objects. No data, no behavior — just a unique identifier.

Components

Pure data containers stored in TypedArrays. Position, velocity, health — raw values only.

Systems

Functions that process entities with specific component sets. All game logic lives here.

Entities#

In Web Engine, entities are just numbers (unsigned 32-bit integers). They have no inherent meaning — they're simply indices into component arrays.

// Create a new entity
const eid = addEntity(world);
// eid is just a number, e.g., 42
console.log(eid); // 42
// Remove an entity
removeEntity(world, eid);

Entity Recycling

When entities are removed, their IDs are recycled. This prevents ID bloat and keeps arrays compact. Web Engine uses bitECS under the hood, which handles this automatically.

Components#

Components are pure data — no methods, no logic. They're defined as schemas and stored in Structure of Arrays (SoA) format for cache efficiency. Web Engine uses bitECS, which stores components as TypedArrays for maximum performance.

packages/core/src/engine/ecs/components/transform.ts
typescript
import { defineComponent, Types } from "bitecs";
// Define a Transform component with nested arrays
export const Transform = defineComponent({
position: [Types.f32, 3], // [x, y, z]
rotation: [Types.f32, 3], // Euler angles
scale: [Types.f32, 3], // [x, y, z]
quaternion: [Types.f32, 4], // [x, y, z, w]
parent: Types.eid, // Parent entity reference
});
// Add component to entity
addComponent(world, Transform, eid);
// Access component data via TypedArrays
Transform.position.x[eid] = 10.0;
Transform.position.y[eid] = 0.0;
Transform.position.z[eid] = -5.0;
// Or using array notation for multi-value fields
const posArray = Transform.position;
posArray[0][eid] = 10.0; // x
posArray[1][eid] = 0.0; // y
posArray[2][eid] = -5.0; // z

Notice how Transform.position.x is a TypedArray. Accessing Transform.position.x[eid] reads the X position for entity eid. Web Engine includes dozens of components covering transform, physics, rendering, animation, audio, and gameplay.

Real Component Examples#

packages/core/src/engine/ecs/components/physics.ts
typescript
// RigidBody component for physics simulation
export const RigidBody = defineComponent({
velocity: [Types.f32, 3], // Linear velocity
angularVelocity: [Types.f32, 3], // Angular velocity
mass: Types.f32, // Mass in kg
friction: Types.f32, // Coefficient of friction
restitution: Types.f32, // Bounciness
type: Types.ui8, // 0=Dynamic, 1=Fixed, 2=Kinematic
ccd: Types.ui8, // Continuous collision detection
lockRotation: [Types.ui8, 3], // Lock X, Y, Z rotation
});
// Collider component for collision detection
export const Collider = defineComponent({
type: Types.ui8, // Shape: Box, Sphere, Capsule, etc.
size: [Types.f32, 3], // Shape dimensions
offset: [Types.f32, 3], // Local offset
isSensor: Types.ui8, // Is trigger volume
collisionGroup: Types.ui16, // Collision group bitmask
collisionMask: Types.ui16, // Collision filter mask
});

Zero-GC Rule

Never create objects in the hot loop! Use pre-allocated vectors and reuse them. The ECS pattern enables this by keeping all data in flat TypedArrays. Web Engine achieves zero garbage collection during gameplay by following this principle religiously.

Systems#

Systems are functions that iterate over entities matching a specific query (set of components). They're where all game logic lives.

MovementSystem.ts
typescript
import { defineQuery, defineSystem } from "bitecs";
// Define a query for entities with Transform and Velocity
const movingQuery = defineQuery([Transform, Velocity]);
// Define the system
export const MovementSystem = defineSystem((world) => {
const dt = world.dt; // Delta time
// Iterate all entities matching the query
const entities = movingQuery(world);
for (let i = 0; i < entities.length; i++) {
const eid = entities[i];
// Apply velocity to position
Transform.position.x[eid] += Velocity.x[eid] * dt;
Transform.position.y[eid] += Velocity.y[eid] * dt;
Transform.position.z[eid] += Velocity.z[eid] * dt;
}
return world;
});

Systems run every frame as part of the game loop. Web Engine organizes them into phases:

  • INPUT — Read input devices, update input state.
  • PRE_PHYSICS — Prepare physics bodies, apply forces.
  • PHYSICS — Step the physics simulation.
  • POST_PHYSICS — Read physics results, sync transforms.
  • GAMEPLAY — Game logic, AI, scripts.
  • ANIMATION — Update skeletal animations.
  • PRE_RENDER — Sync ECS state to Three.js.
  • RENDER — Draw the frame.

Performance Benefits#

ECS achieves exceptional performance through several mechanisms:

Cache Efficiency#

Components are stored in contiguous TypedArrays. When a system iterates over entities, it reads memory sequentially, maximizing CPU cache hits. This is critical for high-performance games targeting 60+ FPS.

Traditional OOP (Array of Structures):
Player1 -> [pos, vel, health, ...] <- scattered in memory
Player2 -> [pos, vel, health, ...] <- cache miss likely
Enemy1 -> [pos, vel, health, ...] <- cache miss likely
ECS (Structure of Arrays):
Position.x: [p1.x, p2.x, e1.x, ...] <- contiguous, cache-friendly
Position.y: [p1.y, p2.y, e1.y, ...] <- contiguous
Position.z: [p1.z, p2.z, e1.z, ...] <- contiguous
Velocity.x: [p1.vx, p2.vx, e1.vx, ...] <- contiguous
...
Result: ~10-100x better cache hit rate in tight loops

Zero Garbage Collection#

Because data lives in pre-allocated TypedArrays, there's no object creation during gameplay. This eliminates GC pauses that cause stuttering. Web Engine can run for hours without triggering a single garbage collection cycle.

// BAD: Creates garbage every frame
function updateBad(eid: number) {
const pos = { x: 0, y: 0, z: 0 }; // Allocates object
pos.x = Transform.position.x[eid];
pos.y = Transform.position.y[eid];
pos.z = Transform.position.z[eid];
return pos; // More garbage
}
// GOOD: Zero allocations
function updateGood(eid: number) {
const x = Transform.position.x[eid];
const y = Transform.position.y[eid];
const z = Transform.position.z[eid];
// Work directly with primitives
}

Easy Parallelization#

Systems can run in parallel if they don't modify the same components. The clear data boundaries make it easy to identify safe parallelization. Web Engine uses Web Workers for physics and culling systems.

Performance Benchmarks#

Web Engine's ECS architecture enables:

  • 100,000+ entities running physics and rendering at 60 FPS
  • 10,000+ AI agents with behavior trees and pathfinding
  • Sub-millisecond entity creation and destruction
  • Zero GC pauses during gameplay (verified with Chrome DevTools)
  • WASM-accelerated transform hierarchies and culling

Why bitECS?#

Web Engine is built on bitECS, the fastest ECS library for JavaScript. bitECS was chosen for several reasons:

  • TypedArray storage — All data in contiguous memory
  • Automatic query caching — O(1) query results after first call
  • Entity recycling — Reuses entity IDs to keep arrays compact
  • Tiny bundle size — ~5KB minified and gzipped
  • Zero dependencies — No external libraries required
  • Battle-tested — Used in production games and simulations

bitECS Features Used#

import {
createWorld, // Create ECS world
addEntity, // Add new entity
removeEntity, // Remove entity
addComponent, // Add component to entity
removeComponent, // Remove component
hasComponent, // Check if entity has component
defineQuery, // Create entity query
enterQuery, // Query for entities that just entered
exitQuery, // Query for entities that just exited
defineComponent, // Define component schema
Types, // TypedArray types (f32, ui8, etc.)
Not, // Query modifier: exclude component
} from 'bitecs';

Best Practices#

  • Keep components small — Only include data that's often accessed together.
  • Prefer composition — Combine simple components rather than creating mega-components.
  • Systems should be focused — Each system does one thing well.
  • Use queries wisely — Define queries once, reuse them. bitECS caches query results.
  • Pool everything — Reuse vectors, matrices, and other objects.
  • Never allocate in hot loops — Use TypedArrays and pre-allocated buffers.
  • Profile with DevTools — Use Chrome's Performance tab to find bottlenecks.
ECS Architecture | Web Engine Docs