logo

Babylon.js Market

By Lawrence

8 minutes

The ECS Mental Model

TL;DR — We watch a classA blueprint for making objects of one type, bundling their fields and methods together. hierarchy break on Pac-Man's possessable ghost. Then we rebuild it in ECSEntity Component System, the architecture that builds game objects by composing data and behavior instead of subclassing them.: data-only components, queryThe filter a system declares so the framework hands it only the entities whose components and tags match.-driven systems, and a WorldThe @babylonjsmarket/ecs object that owns every entity and system and drives the frame loop via update. that runs them each frameOne tick of the game's heartbeat - the game redraws dozens of times a second, and each redraw is a frame.. We end with the smallest loop you can run. Skip this if you have already built on an entityA bare ID with a container of components and tags; it holds no behavior of its own.-componentA pure-data block of fields you attach to an entity; it carries state, never logic.-systemA class that declares a query and runs behavior every frame over the entities that match it. and know why compositionBuilding behavior by combining small independent parts at runtime, rather than locking it into a class hierarchy. beats an inheritanceDeriving one class from another so it reuses and extends the parent's fields and methods. tree.

This course teaches you @babylonjsmarket/ecs, the framework behind these arcade games. We cover one idea per lesson. By the end, building a game from parts will feel normal. We start with the idea the other eight lessons build on. It is called ECS, short for Entity Component System. Most game frameworks use this pattern to organize a game. To see why it exists, we first watch the usual approach fall apart.

Say you are building Pac-Man. A class is a blueprint for making objects of one type. A hierarchy stacks classes so a more specific class reuses a general one. You start with a clean class hierarchy. GameObject sits at the root. Enemy sits below it. Ghost sits below that. Blinky, Pinky, Inky, and Clyde are all instances of Ghost. This is the textbook way to do it.

Then the design changes, because designs always change. A power pellet makes ghosts flee. So Ghost gets a frightened flag and a tangle of if statements. Next, Pac-Man can possess a ghost and steer it. But the input code lives in Player, on a branch you cannot reach. Here is where this approach breaks down:

New to reading TypeScript? A few marks show up below. class X extends Y means X is built on top of Y. update(dt: number) is a named block of code, called a function. It takes one input called dt, and that input's type is number. { } wraps the body of a class or function. import pulls a name in from another file. You do not need to read every line. The comments and prose carry the idea.

// The object-oriented (OOP) version we're leaving behind. Don't copy this.
class GameObject { x = 0; y = 0; update(dt: number) {} }

class Enemy extends GameObject {
  health = 1;
  chasePlayer(dt: number) { /* pathfinding toward Pac-Man */ }
}

class Ghost extends Enemy {
  frightened = false;
  update(dt: number) {
    if (this.frightened) this.fleePlayer(dt);
    else this.chasePlayer(dt);
  }
  fleePlayer(dt: number) { /* run away */ }
}

class PossessableGhost extends Ghost {
  possessed = false;
  update(dt: number) {
    if (this.possessed) this.readPlayerInput(); // wait — this is Player code
    else super.update(dt);
  }
  readPlayerInput() { /* copy-pasted from Player. uh oh. */ }
}

PossessableGhost is where this breaks. It needs ghost behavior and player behavior. But a class has only one parent. So you copy the input code over from Player. Now you have two copies, and they drift apart with every fix. This is the diamond problemThe conflict that arises when one class needs behavior from two parents that a single-parent tree can't supply.. Games run into it all the time, because game design is about mixing behaviors. Inheritance asks you to plan every combination up front, as a tree. Designers do not think in trees. They think, "what if the fruit could also shoot?"

The problem always looks the same. A design change asks for behavior from a branch you cannot reach.

Designer asks forInheritance answerWhat breaks
Ghosts that flee a power pelletfrightened flag + if in Ghost.updateLogic piles up in one update method
A ghost Pac-Man can possessNeeds Ghost and Player behaviorOne parent only — copy the input code
Fruit that flees like a ghostA new class, or hoist flee logic upFlee logic now lives in the wrong place
A boss that does all of itA class inheriting from… everythingThe diamond; no single tree fits

Most game frameworks solve this a different way. They use the ECS pattern, which this course is built on.

Think in data and behavior, not object types

ECS stops asking "what is this object?" It asks two other questions instead. What does this entity have? And what processes it?

So there is no PossessableGhost class. There is just a plain entity, which is really only an ID. That entity has a Position, a GhostBrain, and a Possessable. The behavior lives in systems. Each frame, the systems sweep the world and ask "give me everything with a GhostBrain." A web app waits for events. A game is different. It redraws over and over, dozens of times a second. Each redraw is a frame, and your systems run once per frame.

Side-by-side comparison diagram: left side shows a rigid OOP class inheritance tree labeled GameObject, Enemy, Ghost, PossessableGhost with a red broken diamond where Ghost and Player branches collide; right side shows an ECS entity as a simple ring container holding three detachable component blocks labeled Position, GhostBrain, and Possessable, dark background, neon green and cyan accents, arcade pixel styling

You build behavior by snapping data blocks onto an ID. No new class, no changes to a tree. ECS is four ideas working together. Each one is simpler than its name.

The four parts of ECS: entity, component, system, World

An entity is an ID plus a container. It has no behavior and no update method. It holds components and string tags. A tagA plain string label on an entity, like "player", that systems can match without a dedicated component. is a label like "player" that you can match on without a full component. A component holds fields and nothing else. Position holds x, y, z. GhostBrain holds a mode and a target. You do not have to remember the "fields and nothing else" rule. It is built into the base class. Here is the real Component from the package. It has two override hooks, one for attach and one for detach, and no per-frame method at all.

@babylonjsmarket/ecs/src/ECS/Component/Component.ts
onAttach(entityId: EntityId, eventBus: EventBus): void {
  this.entityId = entityId;
  this.eventBus = eventBus;
  // Call the override hook for subclass initialization
  this.onAttachOverride();
}

/**
 * Called when this component is removed from an entity.
 * Cleans up references and calls the detach override hook.
 */
onDetach(): void {
  // Call the override hook first so subclasses can clean up
  this.onDetachOverride();
  // Clear references to prevent memory leaks
  this.entityId = undefined;
  this.eventBus = undefined;
}

/**
 * Override this method in subclasses to run initialization logic
 * when the component is attached to an entity.
 *
 * Example: A Physics component might create a physics body here.
 */
protected onAttachOverride(): void {}

/**
 * Override this method in subclasses to run cleanup logic
 * when the component is removed from an entity.
 *
 * Example: A Physics component might destroy its physics body here.
 */
protected onDetachOverride(): void {}

There is no update method here. A component reacts when it joins an entity and when it leaves. That is all it does. The per-frame work happens somewhere else.

It happens in a system. A system declares a query. Every frame, the framework hands it the entities that match. The system implements onUpdate(dt), where dt is the seconds since the last frame. The query is the other half of ECS that lives in the source. It is the ISystemQuery interface. It has five filter fields, and the framework reads them to decide which entities a system gets:

@babylonjsmarket/ecs/src/ECS/System/System.ts
export interface ISystemQuery {
  /** Entity must have ALL of these components */
  required?: ComponentType[];

  /** Entity must have NONE of these components */
  excluded?: ComponentType[];

  /** Entity must have AT LEAST ONE of these components */
  anyOf?: ComponentType[];

  /** Entity must have ALL of these tags */
  tags?: string[];

  /** Entity must have NONE of these tags */
  excludedTags?: string[];
}

A system declares its query once. Then the framework keeps the matching set filled. No system ever goes hunting for its entities. This lesson uses only required. All five filters get their own lesson later.

The World is in charge. Every frame you call world.update(dt), and it runs each system's onUpdate. There is one more thing it does. The moment you add or remove a component, the World re-checks which queries that entity matches. This happens right away, not on the next frame.

Data flow diagram of one ECS frame tick: a World box at the top emits update with delta time arrows down to three System boxes labeled MoveSystem, GhostAISystem, RenderSystem, each system connected by a query funnel to only the entity circles whose component blocks match it, entities drawn as rings containing small labeled blocks Position, Velocity, GhostBrain, dark background with neon magenta and cyan arrows, retro arcade schematic style

The OOPObject-Oriented Programming, the style that models a program as classes of objects that inherit from one another. hierarchy crammed all four jobs into one inheritance chain. That is why it kept breaking. ECS gives each job to a different part.

IdeaIts one jobHoldsPac-Man example
EntityIdentityAn ID, a set of components, a set of tagsBlinky; one pellet; the score readout
ComponentStatePure data fields, no logicPosition { x, y, z }, Health { current, max }
SystemBehaviorA query + an onUpdate(dt)GhostAISystem steers everything with a GhostBrain
WorldOrchestrationEvery entity, every system, the frame loopworld.update(dt) ticks all systems once, in priority order (covered later; with one system, order doesn't matter yet)

The smallest runnable ECS program

Here is the smallest real program you can write with @babylonjsmarket/ecs. It has one component, one system, and one entity, drifting steadily to the right.

First the component. It is pure data, exactly as the base class requires:

import { Component } from '@babylonjsmarket/ecs';

// A component is just fields. No methods, no update loop.
class Position extends Component {
  x = 0;
  y = 0;
  z = 0;
}

Now the system. It declares required: [Position] and implements onUpdate:

import { System } from '@babylonjsmarket/ecs';

class MoveSystem extends System {
  constructor() {
    super(); // the World injects the event bus for us — more on that in a later lesson
    // The framework reads this query and keeps this.entities
    // filled with every matching entity. We never search for them.
    this.query = { required: [Position] };
  }

  protected onUpdate(dt: number): void {
    // this.entities is a Set the World manages for us.
    // for...of means "do this once for every entity in the set."
    for (const entity of this.entities) {
      const pos = entity.get(Position)!; // ! says "this exists"; the query guarantees it
      pos.x += 2 * dt; // += adds to x; two units per second, frame-rate independent
    }
  }
}

The query is the filter. this.entities gains an entity the moment it gains a Position. It loses that entity the moment the Position is gone. This is why the trailing ! in entity.get(Position)! is safe. The ! promises the language that this value exists, so the language stops warning us. The * dt keeps movement frame-rate independent. Drop it, and pos.x += 2 runs faster on a 144 Hz monitor than on a 60 Hz one.

Finally the bootstrap, where the World starts up:

// import pulls these names in from the framework's files.
import { World } from '@babylonjsmarket/ecs';
import { BabylonAdapter } from '@babylonjsmarket/ecs/babylon';

const canvas = document.querySelector('canvas')!;

// The renderer adapter wraps BabylonJS so the rest of
// your game never touches the engine directly.
const renderer = new BabylonAdapter(); // new builds one fresh adapter
await renderer.init(canvas); // await waits for the slow setup to finish before moving on

const world = new World({ renderer });

// Add a system by its class — the World constructs it
// and injects the event bus automatically.
world.addSystem(MoveSystem);

// An entity is born empty — the string is just its id,
// which makes it readable in debuggers. Components give it meaning.
const player = world.createEntity('Player');
player.add(new Position());
player.addTag('player');

// The heartbeat: every frame, update the world.
// (dt) => world.update(dt) is a small function handed to startLoop;
// => means "given dt, do this." startLoop calls it once per frame.
renderer.startLoop((dt) => world.update(dt));

Run that and you have a working ECS loop. Nothing shows on screen yet. Position is just numbers until a render component joins it. That render component is the arcade library's MeshPrimitive, and it arrives in the scenes lesson. Even so, every piece is running. The World ticks, the query matches, and the system moves the data.

The new BabylonAdapter() line is worth a look even now. The World never imports BabylonJSThe 3D rendering engine that draws the scene; @babylonjsmarket/ecs drives it through the renderer adapter.. It takes a renderer adapterThe seam that wraps a graphics engine so the rest of the game never touches Babylon or Three directly. and talks to the graphics engine only through that adapter. To swap the engine, you change one line here. The last lesson shows how. For now, notice that no component or system above names BabylonJS at all.

An entity does not have many methods. You can attach, read, check, and remove:

const ghost = world.createEntity('Inky');

ghost.add(new Position());        // attach a component
ghost.addTag('enemy');            // attach a tag

ghost.has(Position);              // true
ghost.get(Position);              // the Position, or undefined
ghost.hasTag('enemy');            // true

ghost.remove(Position);           // detach a component

The methods are named get and has. Some other ECS libraries call these getComponent and hasComponent, but there is no such pair here.

Building the possessable ghost with composition

The possessable ghost that broke the hierarchy is not a class anymore. It is a recipe. GhostBrain and Possessable are more data-only components, like Position. We build them for real in a later lesson, so do not look for their full definitions here:

const ghost = world.createEntity('Inky');
ghost.add(new Position());
ghost.add(new GhostBrain());   // chase/flee logic's data
ghost.add(new Possessable());  // marks it as steerable by the player
ghost.addTag('enemy');

The GhostAISystem queries for GhostBrain and steers it. A PossessionSystem queries for Possessable and routes input to it. Neither system knows the other exists. The fleeing fruit is the same, but with a GhostBrain and no Possessable. The boss adds every component. There is no diamond because there is no tree. There is only data, and systems that match on it.

Three arcade entities as glowing ring containers on a dark grid: a ghost entity holding Position, GhostBrain, and Possessable blocks; a fruit entity holding Position and GhostBrain blocks; a wall entity holding Position and Health blocks; shared component types drawn in matching neon colors across entities to show reuse, cyan magenta and green palette, retro arcade aesthetic

Here is the big shift in one line. Inheritance answers "what is it?" once, at design time, and that answer is fixed. Composition answers "what does it have?" at runtime, for each entity, and you can change it. A frightened ghost is not a new kind of thing. It is the same entity with one field flipped, and the systems follow the data.

This changes debugging, too. In OOP, behavior hides inside methods spread across a hierarchy. In ECS, the whole game state is data sitting on entities. So you can dump the world and read it directly.

This is how you will think for the rest of the course. Every gameplay idea breaks down into three parts: some data (a component), some logic that processes it (a system), and a query that connects them. Next we build them for real. You will see defaulted constructors, the one method every system must implement, and a hand-written component shown next to one straight from the arcade library.

Next: Writing Components and Systems — the defaulted-field constructor pattern, the single required onUpdate override, and the data split that keeps serialize and clone working.

Was this page helpful?

We read every note — tell us what's working and what isn't.

↑↓ NavigateEnter SelectEsc CloseCtrl+K Open Search