Three.js From Zero · Article s8-02

S8-02 Entity Component Systems

Season 8 · Article 02

Entity Component Systems

Past 20 different "classes" of things, inheritance hierarchies collapse. ECS: every object is just an ID, "components" are bags of data, "systems" iterate over all entities with matching components. Data-oriented, cache-friendly, scales.

1. The inheritance trap

class Entity { ... }
class Mesh extends Entity { ... }
class Movable extends Mesh { ... }
class Player extends Movable { ... }
class FlyingPlayer extends Player { ... }  // flying + regular player?
// Now you need a FlyingNPC. Does it extend FlyingPlayer or Movable?

Diamond inheritance. Deep hierarchies. "Player with fire sword" needs a new class. Dies at 50+ types.

2. ECS in one sentence

Entities are IDs. Components are data (no logic). Systems iterate entities with matching components.

3. Concrete example

// Components: just data
const Position = { x: 0, y: 0, z: 0 };
const Velocity = { x: 0, y: 0, z: 0 };
const Health   = { current: 100, max: 100 };
const Renderable = { mesh: THREE.Mesh };

// Entity: just an ID with components attached
entity[42] = { Position, Velocity, Renderable };

// System: logic that operates on entities with matching components
function MovementSystem(dt) {
  for (const e of query([Position, Velocity])) {
    e.Position.x += e.Velocity.x * dt;
    e.Position.y += e.Velocity.y * dt;
    e.Position.z += e.Velocity.z * dt;
  }
}

4. bitECS (JS library, SoA)

import { createWorld, addEntity, addComponent, defineComponent, defineQuery, Types } from 'bitecs';

const world = createWorld();
const Position = defineComponent({ x: Types.f32, y: Types.f32, z: Types.f32 });
const Velocity = defineComponent({ x: Types.f32, y: Types.f32, z: Types.f32 });

const eid = addEntity(world);
addComponent(world, Position, eid);
addComponent(world, Velocity, eid);
Position.x[eid] = 0;
Velocity.x[eid] = 1;

const movementQuery = defineQuery([Position, Velocity]);

function movementSystem(world, dt) {
  for (const eid of movementQuery(world)) {
    Position.x[eid] += Velocity.x[eid] * dt;
  }
}

Structure-of-Arrays. All Positions contiguous in memory. Iteration is L1-cache fast.

5. Miniplex (tiny, ergonomic)

import { World } from 'miniplex';

const world = new World();
const e = world.add({ position: {x:0}, velocity: {x:1}, mesh: threeMesh });

for (const { position, velocity } of world.archetype('position','velocity')) {
  position.x += velocity.x;
}

No decoder ring. 300 lines of source code. Great for < 10k entity projects.

6. Live demo — 5000-entity ECS

5000 entities, 3 components (Position, Velocity, Renderable). Runs at 60fps because systems loop flat arrays.

7. System design

  • MovementSystem: Position + Velocity
  • GravitySystem: Velocity + GravityAffected
  • HealthSystem: Health (auto-heal, death detect)
  • CollisionSystem: Position + Collider
  • RenderSystem: Position + Rotation + Renderable → writes to Three.js Object3D

Order matters: physics → collision → render. Systems run in a pipeline each frame.

8. Tag components

No data, just a flag:

const IsPlayer = defineComponent({});  // empty
const IsEnemy = defineComponent({});

// Query
const players = defineQuery([IsPlayer, Position]);

9. When ECS overkill

Small scene, < 20 objects: plain classes fine. Prototype / game jam: classes fine. Production game, editor, sim: ECS after ~50+ distinct behaviors.

10. Takeaways

  • ECS decouples data (components) from behavior (systems).
  • Entities are just IDs. Composable via components.
  • bitECS for perf, Miniplex for ergonomics.
  • Systems run in a fixed order each frame.
  • Scales to tens of thousands of entities.