AR
AgentRuss
JavaScript Entity Component System architecture showing entities, components, queries, systems, and the ECS world.
AgentRuss Guide

How to Build an Entity Component System (ECS) in JavaScript

Written by
AgentRuss
Published

If you're new to ECS, I recommend reading Understanding Entity Component Systems (ECS): A Beginner-Friendly Guide first. If you already understand the basics but are unsure where responsibilities belong, see Entities vs Components vs Systems in ECS: What Goes Where?.

In this guide, we're going to build a small but functional Entity Component System in plain JavaScript.

The goal is not to create the fastest ECS ever made. Instead, we want an implementation that clearly demonstrates the core architecture:

  • entities are IDs
  • components contain data
  • systems contain behaviour
  • queries find entities with the required components
  • the world coordinates everything

By the end, we'll have multiple entities moving through the same MovementSystem without needing Player, Enemy, or GameObject classes.


What We're Building

Our minimal ECS will contain five main pieces:

  • World — owns entities, components, and systems
  • Entity — a unique numeric ID
  • Component — data attached to an entity
  • Query — finds entities with a required set of components
  • System — runs logic on matching entities

The flow looks like this:

JavaScript
1World
2 ├── Entities
3 ├── Components
4 └── Systems
5
6 └── Query matching entities
7
8 └── Update component data

For our example, an entity with both Transform and Velocity will automatically be processed by MovementSystem.


Project Structure

A simple folder structure could look like this:

JavaScript
1src/
2├── ecs/
3│ ├── Component.js
4│ ├── System.js
5│ └── World.js
6
7├── components/
8│ ├── Transform.js
9│ └── Velocity.js
10
11├── systems/
12│ └── MovementSystem.js
13
14└── main.js

This is deliberately small.

As an engine grows, you might later add things such as Query.js, EventBus.js, InputSystem.js, DamageSystem.js, collision systems, rendering systems, and many more components.

For now, we only need enough structure to understand the pattern.


1. Create a Base Component

Create src/ecs/Component.js:

JavaScript
1export class Component {}

That's all we need.

The base class gives our components a common structure, but it contains no gameplay logic.

A component's job is to store state.

For example, a Health component might eventually contain current and max values, while a Transform component stores position.

The important part is that components do not decide what should happen to that data.


2. Create a Base System

Create src/ecs/System.js:

JavaScript
1export class System {
2 constructor(world) {
3 this.world = world
4 }
5
6 update(deltaTime) {}
7}

Every system receives a reference to the World.

That lets systems query entities and retrieve their components.

Each concrete system will override update(deltaTime) with its own behaviour.

For example:

  • MovementSystem changes positions
  • DamageSystem resolves damage
  • StatsSystem calculates final stats
  • RenderSystem draws the scene

3. Build the World

The World is the central container for our ECS.

Create src/ecs/World.js and start with:

JavaScript
1export class World {
2 constructor() {
3 this.nextEntityId = 1
4 this.entities = new Set()
5 this.componentStores = new Map()
6 this.systems = []
7 }
8}

Each property has a specific job.

nextEntityId gives every new entity a unique ID.

entities tracks which entities currently exist.

componentStores contains component data grouped by component type.

systems contains the systems that should run each update.

The component storage will conceptually look something like this:

JavaScript
1Transform
2 Entity 1 -> Transform data
3 Entity 2 -> Transform data
4 Entity 5 -> Transform data
5
6Velocity
7 Entity 1 -> Velocity data
8 Entity 5 -> Velocity data

Notice that the entity itself does not own those components directly.

The world manages the relationship between entity IDs and component data.


4. Create and Destroy Entities

Add these methods to World:

JavaScript
1createEntity() {
2 const entity = this.nextEntityId++
3
4 this.entities.add(entity)
5
6 return entity
7}
8
9destroyEntity(entity) {
10 if (!this.entities.has(entity)) {
11 return false
12 }
13
14 for (const store of this.componentStores.values()) {
15 store.delete(entity)
16 }
17
18 this.entities.delete(entity)
19
20 return true
21}

Now we can create an entity with:

JavaScript
1const player = world.createEntity()

If it is the first entity, player will simply contain the number 1.

There is no position, health, inventory, mesh, or movement behaviour inside that value.

It is only an identity.

When we destroy an entity, we remove both its ID and all component data associated with it.


5. Add Component Storage

Now the world needs methods for attaching, retrieving, checking, and removing components.

Add these methods:

JavaScript
1addComponent(entity, component) {
2 const componentType = component.constructor
3
4 if (!this.componentStores.has(componentType)) {
5 this.componentStores.set(componentType, new Map())
6 }
7
8 const store = this.componentStores.get(componentType)
9
10 store.set(entity, component)
11
12 return component
13}
14
15getComponent(entity, componentType) {
16 const store = this.componentStores.get(componentType)
17
18 if (!store) {
19 return undefined
20 }
21
22 return store.get(entity)
23}
24
25hasComponent(entity, componentType) {
26 const store = this.componentStores.get(componentType)
27
28 return store?.has(entity) ?? false
29}
30
31removeComponent(entity, componentType) {
32 const store = this.componentStores.get(componentType)
33
34 if (!store) {
35 return false
36 }
37
38 return store.delete(entity)
39}

The component class itself becomes the key in componentStores.

If we attach new Transform(), the Transform class identifies the store containing Transform components.

This lets us retrieve data later with:

world.getComponent(entity, Transform)

and check composition with:

world.hasComponent(entity, Transform)


6. Create Transform and Velocity Components

Now let's create some actual game data.

Create src/components/Transform.js:

JavaScript
1import { Component } from '../ecs/Component.js'
2
3export class Transform extends Component {
4 constructor(x = 0, y = 0, z = 0) {
5 super()
6
7 this.position = {
8 x,
9 y,
10 z,
11 }
12 }
13}

Then create src/components/Velocity.js:

JavaScript
1import { Component } from '../ecs/Component.js'
2
3export class Velocity extends Component {
4 constructor(x = 0, y = 0, z = 0) {
5 super()
6
7 this.x = x
8 this.y = y
9 this.z = z
10 }
11}

Both components contain data only.

Transform describes where an entity is.

Velocity describes how quickly it is moving.

Neither component contains a move() method.

Movement behaviour will belong to a system.


7. Compose Our First Entity

In main.js, create a world and an entity:

JavaScript
1import { World } from './ecs/World.js'
2
3import { Transform } from './components/Transform.js'
4import { Velocity } from './components/Velocity.js'
5
6const world = new World()
7
8const player = world.createEntity()
9
10world.addComponent(
11 player,
12 new Transform(0, 0, 0),
13)
14
15world.addComponent(
16 player,
17 new Velocity(2, 0, 0),
18)

We can now think of the entity like this:

JavaScript
1Entity 1
2 ├── Transform
3 │ position: 0, 0, 0
4
5 └── Velocity
6 x: 2
7 y: 0
8 z: 0

The entity has no Player class.

It behaves like something that can move because it has the data required by a movement system.

That is the composition-based nature of ECS.


8. Add Queries

Systems need a way to find entities with particular combinations of components.

Add this method to World:

JavaScript
1query(componentTypes) {
2 const results = []
3
4 for (const entity of this.entities) {
5 const matches = componentTypes.every(
6 (componentType) =>
7 this.hasComponent(entity, componentType),
8 )
9
10 if (matches) {
11 results.push(entity)
12 }
13 }
14
15 return results
16}

Now we can ask:

JavaScript
1const movingEntities = world.query([
2 Transform,
3 Velocity,
4])

That means:

Find every entity that has both Transform and Velocity.

Imagine the world contains:

JavaScript
1Entity 1
2 Transform
3 Velocity
4
5Entity 2
6 Transform
7
8Entity 3
9 Transform
10 Velocity
11 Health

A query for Transform + Velocity would return Entity 1 and Entity 3.

Entity 2 would not match because it has no Velocity.

This is a key ECS concept: systems operate on capabilities rather than entity types.


9. Create MovementSystem

Now we can finally add behaviour.

Create src/systems/MovementSystem.js:

JavaScript
1import { System } from '../ecs/System.js'
2
3import { Transform } from '../components/Transform.js'
4import { Velocity } from '../components/Velocity.js'
5
6export class MovementSystem extends System {
7 update(deltaTime) {
8 const entities = this.world.query([
9 Transform,
10 Velocity,
11 ])
12
13 for (const entity of entities) {
14 const transform =
15 this.world.getComponent(entity, Transform)
16
17 const velocity =
18 this.world.getComponent(entity, Velocity)
19
20 transform.position.x += velocity.x * deltaTime
21 transform.position.y += velocity.y * deltaTime
22 transform.position.z += velocity.z * deltaTime
23 }
24 }
25}

The system asks for every entity with Transform and Velocity.

It then updates their position using their velocity.

Notice what the system does not check.

It never asks whether the entity is a player, enemy, projectile, NPC, or moving platform.

If an entity has the required components, it can move.


10. Register and Update Systems

Add these methods to World:

JavaScript
1addSystem(system) {
2 this.systems.push(system)
3
4 return system
5}
6
7update(deltaTime) {
8 for (const system of this.systems) {
9 system.update(deltaTime)
10 }
11}

Then register our movement system:

JavaScript
1import { MovementSystem } from './systems/MovementSystem.js'
2
3world.addSystem(
4 new MovementSystem(world),
5)

The world can now run every registered system with:

world.update(deltaTime)

This also introduces an important architectural detail: system order matters.

Systems execute in the order they are registered.

Later, a game might use an order such as:

JavaScript
1InputSystem
2PlayerControllerSystem
3GravitySystem
4MovementSystem
5CollisionSystem
6CameraSystem
7DamageSystem
8StatsSystem
9RenderSystem

For example, gravity should normally change velocity before movement uses that velocity to update position.


11. Complete World Class

At this point, our minimal World.js looks like this:

JavaScript
1export class World {
2 constructor() {
3 this.nextEntityId = 1
4 this.entities = new Set()
5 this.componentStores = new Map()
6 this.systems = []
7 }
8
9 createEntity() {
10 const entity = this.nextEntityId++
11
12 this.entities.add(entity)
13
14 return entity
15 }
16
17 destroyEntity(entity) {
18 if (!this.entities.has(entity)) {
19 return false
20 }
21
22 for (const store of this.componentStores.values()) {
23 store.delete(entity)
24 }
25
26 this.entities.delete(entity)
27
28 return true
29 }
30
31 addComponent(entity, component) {
32 const componentType = component.constructor
33
34 if (!this.componentStores.has(componentType)) {
35 this.componentStores.set(
36 componentType,
37 new Map(),
38 )
39 }
40
41 const store =
42 this.componentStores.get(componentType)
43
44 store.set(entity, component)
45
46 return component
47 }
48
49 getComponent(entity, componentType) {
50 const store =
51 this.componentStores.get(componentType)
52
53 if (!store) {
54 return undefined
55 }
56
57 return store.get(entity)
58 }
59
60 hasComponent(entity, componentType) {
61 const store =
62 this.componentStores.get(componentType)
63
64 return store?.has(entity) ?? false
65 }
66
67 removeComponent(entity, componentType) {
68 const store =
69 this.componentStores.get(componentType)
70
71 if (!store) {
72 return false
73 }
74
75 return store.delete(entity)
76 }
77
78 query(componentTypes) {
79 const results = []
80
81 for (const entity of this.entities) {
82 const matches = componentTypes.every(
83 (componentType) =>
84 this.hasComponent(
85 entity,
86 componentType,
87 ),
88 )
89
90 if (matches) {
91 results.push(entity)
92 }
93 }
94
95 return results
96 }
97
98 addSystem(system) {
99 this.systems.push(system)
100
101 return system
102 }
103
104 update(deltaTime) {
105 for (const system of this.systems) {
106 system.update(deltaTime)
107 }
108 }
109}

This implementation is intentionally straightforward.

A production ECS can optimize almost every part of it, but all of the important architectural ideas are already present.


12. Run the ECS

Now let's create a simple game loop.

Our main.js can look like this:

JavaScript
1import { World } from './ecs/World.js'
2
3import { Transform } from './components/Transform.js'
4import { Velocity } from './components/Velocity.js'
5
6import { MovementSystem } from './systems/MovementSystem.js'
7
8const world = new World()
9
10const player = world.createEntity()
11
12world.addComponent(
13 player,
14 new Transform(0, 0, 0),
15)
16
17world.addComponent(
18 player,
19 new Velocity(2, 0, 0),
20)
21
22world.addSystem(
23 new MovementSystem(world),
24)
25
26let lastTime = performance.now()
27
28function update(currentTime) {
29 const deltaTime =
30 (currentTime - lastTime) / 1000
31
32 lastTime = currentTime
33
34 world.update(deltaTime)
35
36 const transform =
37 world.getComponent(player, Transform)
38
39 console.log(transform.position.x)
40
41 requestAnimationFrame(update)
42}
43
44requestAnimationFrame(update)

The entity's velocity is two units per second along the X axis.

Every frame:

  1. the world updates
  2. MovementSystem finds the entity
  3. the system reads its Transform and Velocity
  4. position changes

There is no player.move() method anywhere.

Instead:

JavaScript
1Entity
2+
3Transform
4+
5Velocity
6+
7MovementSystem
8=
9Movement

13. Add Another Moving Entity

The benefit becomes clearer when we add another entity.

JavaScript
1const enemy = world.createEntity()
2
3world.addComponent(
4 enemy,
5 new Transform(10, 0, 0),
6)
7
8world.addComponent(
9 enemy,
10 new Velocity(-1, 0, 0),
11)

We don't need to change MovementSystem.

It automatically discovers both entities because both have Transform and Velocity.

The same system could eventually move:

  • players
  • enemies
  • NPCs
  • projectiles
  • moving platforms

The behaviour is reusable because it depends on component composition rather than inheritance.


14. Use Marker Components for Roles

Sometimes a component doesn't need any data at all.

Suppose we want to distinguish an entity controlled by the player.

We can create a marker component:

JavaScript
1import { Component } from '../ecs/Component.js'
2
3export class PlayerControlled extends Component {}

Then attach it:

JavaScript
1world.addComponent(
2 player,
3 new PlayerControlled(),
4)

A future PlayerControllerSystem could query for:

PlayerControlled + Transform + Velocity

while an AI system could process entities with a different marker.

The MovementSystem still doesn't care where movement came from.

That gives us a useful pipeline:

JavaScript
1InputSystem
2
3PlayerControllerSystem
4
5Velocity
6
7MovementSystem

An enemy could instead use:

JavaScript
1AISystem
2
3Velocity
4
5MovementSystem

Both reuse the same movement logic.


15. Components Can Change Behaviour Dynamically

One powerful property of ECS is that behaviour can change when components are added or removed.

Suppose we remove Velocity:

JavaScript
1world.removeComponent(
2 player,
3 Velocity,
4)

The entity still exists.

Its Transform still exists.

But it no longer matches the query used by MovementSystem.

The movement behaviour effectively disappears because the entity no longer has the required composition.

We could later add Velocity again and the existing system would automatically begin processing the entity.

That is very different from designing a rigid inheritance hierarchy where behaviour is permanently defined by an object's class.


16. Why Queries Become Important

Our current query implementation scans all entities every time it runs.

That is perfectly fine for learning the architecture, but it is not necessarily how we would build a highly optimized production ECS.

As the engine grows, we can introduce dedicated query objects that cache matching entities.

Instead of repeatedly scanning:

JavaScript
1Entity 1
2Entity 2
3Entity 3
4Entity 4
5...

a query can maintain a set such as:

JavaScript
1Transform + Velocity
2 Entity 1
3 Entity 3
4 Entity 12
5 Entity 28

Then MovementSystem can operate directly on those entities.

The simple version in this guide is useful because it makes the concept obvious before introducing optimization.


17. Where Events Fit In

As we add gameplay systems, not every interaction should happen through direct component changes.

Sometimes several systems need to know that something happened.

For example:

JavaScript
1DamageSystem
2
3Damage Resolved
4
5DamageNumberUISystem
6HitFlashSystem
7DeathSystem

Instead of making DamageSystem directly call every other system, we can introduce an EventBus.

That allows one system to announce an event while other systems react independently.

A useful distinction is:

  • components represent state
  • events represent something that happened

For example:

Health is state.

DamageResolved is an event.

Equipment is state.

ItemEquipped could be an event.

This becomes increasingly useful as the ECS grows.


18. Don't Put Everything Into ECS

ECS is a powerful architecture, but that doesn't mean every piece of game data needs to become an entity or component.

Static definitions are often better kept as ordinary JavaScript objects.

For example:

JavaScript
1const ItemDatabase = {
2 bronzeSword: {
3 name: 'Bronze Sword',
4
5 modifiers: {
6 attack: 5,
7 },
8 },
9
10 healthPotion: {
11 name: 'Health Potion',
12 healAmount: 25,
13 },
14}

This data describes reusable item definitions.

An Inventory or Equipment component can reference those definitions without turning every configuration object into an ECS entity.

The same applies to things like:

  • ability definitions
  • loot tables
  • enemy archetypes
  • level configuration
  • static game settings

Use ECS where it helps organize runtime state and behaviour.


19. What a Larger ECS Can Become

Our example only contains two components and one system, but the architecture can grow naturally.

Components might eventually include:

JavaScript
1Transform
2Velocity
3Health
4Input
5PlayerControlled
6Inventory
7Equipment
8BaseStats
9FinalStats
10Quickbar
11StatusEffects
12Collider
13Interactable
14CameraFollow

Systems might include:

JavaScript
1InputSystem
2PlayerControllerSystem
3GravitySystem
4MovementSystem
5CollisionSystem
6InteractionSystem
7InventorySystem
8ItemUseSystem
9HealSystem
10StatsSystem
11TargetingSystem
12DamageSystem
13DeathSystem
14RespawnSystem
15RenderSystem

The important thing is that the basic model does not change.

Entities remain IDs.

Components remain state.

Systems remain behaviour.

Queries connect them together.


A Useful ECS Checklist

When adding a new feature, ask a few simple questions.

Does this need its own identity?

Create an Entity.

Does it describe current state?

Use a Component.

Examples include position, health, velocity, inventory contents, or equipped items.

Does it describe behaviour or a game rule?

Use a System.

Examples include movement, damage resolution, stat calculation, targeting, or collision.

Does a system need entities with a particular capability?

Use a Query.

For example, Transform + Velocity means an entity can participate in movement.

Did something happen that several systems may care about?

Consider an Event.

Is it reusable static configuration?

Use an ordinary definition or database object.

This mental model can resolve a surprising number of architecture decisions.


Where to Go Next

The ECS we've built here is intentionally small, but it gives us a foundation for more advanced features.

Natural next steps include:

  • cached Query objects
  • an EventBus
  • input components and input systems
  • fixed-timestep updates
  • collision systems
  • health and damage
  • inventory and equipment
  • stats and modifiers
  • abilities and cooldowns
  • status effects
  • targeting
  • death and respawning
  • rendering with Three.js

Each feature can build on the same foundation rather than requiring us to redesign the entire engine.


Conclusion

We built a functional Entity Component System in JavaScript using only a few core ideas:

JavaScript
1World
2Entities
3Components
4Queries
5Systems

The World coordinates the ECS.

Entities provide identity.

Components provide state.

Queries find entities with the required capabilities.

Systems apply behaviour to those entities.

Most importantly, gameplay emerges from composition.

An entity with Transform and Velocity can move because MovementSystem knows how to process that combination.

Another entity can use exactly the same system without sharing a parent class or duplicating movement code.

As the engine grows, we can make queries faster, add events, introduce fixed-timestep simulation, build combat and inventory systems, and eventually support much more complex gameplay.

But all of those features can grow from the same small architecture we built here.