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 entityconst eid = addEntity(world); // eid is just a number, e.g., 42console.log(eid); // 42 // Remove an entityremoveEntity(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.
import { defineComponent, Types } from "bitecs"; // Define a Transform component with nested arraysexport 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 entityaddComponent(world, Transform, eid); // Access component data via TypedArraysTransform.position.x[eid] = 10.0;Transform.position.y[eid] = 0.0;Transform.position.z[eid] = -5.0; // Or using array notation for multi-value fieldsconst posArray = Transform.position;posArray[0][eid] = 10.0; // xposArray[1][eid] = 0.0; // yposArray[2][eid] = -5.0; // zNotice 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#
// RigidBody component for physics simulationexport 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 detectionexport 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.
import { defineQuery, defineSystem } from "bitecs"; // Define a query for entities with Transform and Velocityconst movingQuery = defineQuery([Transform, Velocity]); // Define the systemexport 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 memoryPlayer2 -> [pos, vel, health, ...] <- cache miss likelyEnemy1 -> [pos, vel, health, ...] <- cache miss likely ECS (Structure of Arrays):Position.x: [p1.x, p2.x, e1.x, ...] <- contiguous, cache-friendlyPosition.y: [p1.y, p2.y, e1.y, ...] <- contiguousPosition.z: [p1.z, p2.z, e1.z, ...] <- contiguousVelocity.x: [p1.vx, p2.vx, e1.vx, ...] <- contiguous... Result: ~10-100x better cache hit rate in tight loopsZero 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 framefunction 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 allocationsfunction 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.