Let's build a basic ECS game engine

An introductory post to the ECS design pattern from the game development world. Experienced web developers will gain a high level intuition for this modern game development architecture.

Calendar icon Aug 03 • 20 Minute Read
Tag icon TypeScript  •  Design Patterns  •  Intermediate

By trade, I am not a game developer. However for this site, I had the idea to build a simple canvas game called Mustachio and throw it inside the hero section of my home page .

Initially, I built Mustachio in an object oriented design pattern where the objects in the game all had their own unique classes with properties and methods etc. The game initially looked something like this:

index.ts
export class Sprite {
  //...sprite stuff
}

export class Mustache extends Sprite {
 public position: {
    x: number,
    y: number
  }
 public velocity: {
    x: number,
    y: number
  }
  //..Mustache types 
  constructor({ position, velocity /*...other input props*/ }) {
    super()
    this.position = position
    this.velocity = velocity
    //..initial state
  }
  thrust() {
    //..thrust the mustache
  }
  shoot() {
    //...code to shoot stuff
  }
  //...other methods
  render(ctx) {
    //...render to canvas
  }
  update(ctx: CanvasRenderingContext2D, g: Globals, e: EnemyPool) {
    this.updateControles()
    this.updatePosition(enemies)
    //...other update functions
    this.render()
  }
}

class Enemy extends Sprite {
  //... enemy stuff here
  render(ctx: CanvasRenderingContext2D) {
    //render function for each enemy
  }
  update(ctx: CanvasRenderingContext2D, g: Globals) {
    //..update the enemy
    this.render(ctx)
  }
}
//... other game objects

class Game {
  enemies: EnemySpawner
  player: Player
  constructor(/*global props*/) {
    //...global props
  }
  initialize() {
    //initialize all game entities
    this.enemies = new EnemySpawner({/*...props*/ })
    this.player = new Player({/*...props*/ })
  }
  //other global methods 
  update() {
    this.ememies.update({/*update props*/ })
    this.player.update({/*update props*/ })
    //... Call global Game methods here
  }
}

const game = new Game()

let frame = requestAnimationFrame()
function loop() {
  frame = requestAnimationFrame()
  //...
  game.update()
}
loop()

As you an see, for the most part, each class represents an entity in the game. For example, the mustache class encapsulates properties and methods that are specific to mustaches. Each object has its own render function, all of which called in the main game loop. All of the classes that have an image inherit from the sprite class etc. etc. This can be a really intuitive way of conceptualizing a game. But this is not the way.

Behold...the ECS pattern

Background line design

In the midst of these OOP antics, I stumbled upon a talk by Blizzard's Timothy Ford where he grants us behind the scenes insight into the game Overwatch and the merits of an ECS architecture. To be honest, when I first saw the idea I wasn’t fully sold, but for the sake of cross pollination and my own education I decided to implement the game using this architecture instead of OOP–and I must say this was a great decision.

Okay, what is an ECS exactly?

In game development, ECS (Entity-Component-System) refers to a composition-based architecture that fosters a clean separation of concerns to improve the overall extensibility and performance of a codebase. To build intuition for this design pattern, let's create a simple game using an ECS pattern without relying on external libraries.

Before we start, it's important to note that this is a high-level implementation. A full-fledged ECS engine would be more nuanced. However, for our purposes here, this approach will suffice. It will provide you with a foundational and intuitive understanding of what an ECS architecture is without diving too deeply into complexities. You can also follow along by checking out this repo with the completed example.

Games intuitively defined

Let's consider what a game actually is. Essentially, a game is a program that takes an initial state (or data), which we'll call a 'world', and then applies mutations through a function or set of rules that are executed recursively many times per second to create the illusion of continuity. Here is a basic implementation of this:

main.ts
import { render } from './render'

export type GameWorld = {
  [key: string]: number
}

//initalize game state
export const world: GameWorld = {
  PlayerPositionY: 20,
  Enemy1PositionY: 20,
  //...
  //propertyN: valueN
}

function game(w: GameWorld): GameWorld {

  //mutate state here

  render(w)

  return w
}

const compose = (w: GameWorld) => {
  setTimeout(() => {
    compose(game(w))
  }, 17)
}

compose(game(world))

Now, we could leave it at that and construct our game program within a single massive 'game' function. However, this approach is objectively unwise in most cases. As our game grows in features and requirements, the game function would become unwieldy. Let's examine a snippet of what this might look like:

main.ts
import { render } from './render'

type GameWorld = {
  [key: string]: number
}

const world: GameWorld = {
  PlayerPositionY: 20,
  Enemy1PositionY: 10,
  PlayerSpeed: 7,
  //...
  //pn: vn
}
function game(w: GameWorld): GameWorld {
  const props = Object.keys(w)

  //Get game entites with speed
  const speedEnts = props
    .filter(prop => {
      if (prop.includes('Speed')) {
        return true
      }
    })
    .map(prop => prop.replace('Speed', ''))

  if (speedEnts.length) {
    //update game entity positions
    speedEnts.forEach(ent => {
      if (ent.includes('Player')) {
        //handle player position changes here
        //...
      }
      if (ent.includes('Enemy')) {
        //handle enemy position changes here
        //...
      }
    })
  }
  
  //handle spawning entites 
  //...

  //handle collisions
  //...

  //...

Now, this is where those adhering to an Object-Oriented paradigm might suggest breaking down the world (game state) into separate classes, each representing specific game entities. For example, we might create a "Player" class with properties like "Position" and "Speed," along with methods related to updating player entities, such as "handleControls" and "updatePosition" etc. Here's an example:

main.ts
type GlobalState = {
  //...global state types
}

class Player {
  public position: { x: number, y: number }
  public speed: number
  //...other type defs
  constructor(/*...props*/) {
    this.position = { x: 20, y: 20 }
    this.speed = 10
    //...other Player properties
  }

  handleControles() {
    //...controles functionaility
  }

  updatePosition() {
    //...update the position based on controles
  }
  
  //...other player methods

  render(ctx: CanvasRenderingContext2D) {
    //...render to screen
  }

  update(g: GlobalState) {
    this.handleControles()
    this.updatePosition()
    //...other high level updates
    render(g.ctx)
  }
}

class EnemyPool {
  //...enemy pool class
}
//...other classes

const globals: GlobalState = {
  ctx: //...canvas context,
  //... initailize global state
}
const player = new Player(/*...props*/)
const enemyPool = new EnemyPool(/*...props*/)
//...initialize other game objects

function game(g: GlobalState): GlobalState {
  player.update(g)
  enemyPool.update(g)
  //...other object updates 
  return g
}

const compose = (g: GlobalState) => {
  setTimeout(() => {
    compose(game(g))
  }, 17)
}

compose(game(globals))

While this approach is acceptable, it can become challenging to maintain as the game's requirements expand or change because state properties and game functionality are tightly coupled. Moreover, for many games, especially those involving extensive interaction between game entities, this structure may not be the most performance-efficient. The ECS design pattern proposes that we transition to a composition based mindset where game functionality and game state are modularized in such a way that no one entity exclusively owns any method or property.

Systems

To break down our game function into smaller, more manageable parts without tying them to specific entities, we can represent it as a composition of functions, each responsible for updating specific categories of game state. In the ECS design pattern, these functions are referred to as "Systems." Let's disassemble our main function into a composition of systems, each responsible for updating its specific category of game state:

main.ts
import { render } from './render'
import { collision, movement, spawn } from './systems'

type GameWorld = {
  [key: string]: number
}

//initalize game state
const world: GameWorld = {
  PlayerPositionY: 20,
  Enemy1PositionY: 10,
  PlayerSpeed: 7,
  //...
  //propertyN: valueN
}

function pipe(
  init: GameWorld,
  ...funcs: Function[]
): GameWorld {
  return funcs.reduce((acc, func) => func(acc), init);
}

function game(w: GameWorld): GameWorld {

  const next = pipe(
    w,
    spawn,
    movement,
    collision
  )

  render(next)

  return next
}

const compose = (w: GameWorld) => {
  setTimeout(() => {
    compose(game(w))
  }, 17)
}

compose(game(world))

Notice how the categories we chose to update are at a higher level than any individual entity. For instance, we don't have a "Player" system that exclusively handles player-related mutations. Instead, we choose to update game entities with movement, collisions, or any other category simultaneously.

Components

This perspective on game functionality compels us to rethink how we might represent the game world. Currently, we inefficiently store all game state within one unorganized object called 'world.' This approach is objectively suboptimal for most games. Instead, we can define a ‘Component’ as a property of the game world that has been abstracted away from any single entity. For instance, 'PlayerPositionY' can become the 'PositionY' component:

main.ts
//...
const world: GameWorld = {
  PositionY: //?,
  Speed: //?,
  //...
}
//...

However, we encounter a problem: how can we determine which game entities possess certain components? Does the ‘Speed’ component apply to a ‘Player’ entity in the game? If so, how do we keep track of a player’s speed value?

Entity

This is where 'Entity' in ECS comes into play. We can define an Entity as a unique ID and use this ID as a reference to specific values associated with a given component. For example, if the player entity is assigned the ID 0, we can use a data structure that associates ID 0 with a specific element within the set of values for the "Speed" component:

Diagram

Now, there's no consensus on the best data structures to achieve this idea, and the specific approach depends on a program’s complexity. For the sake of this example, I'll use a straightforward approach. Each component's value in our 'world' object will be a Float64Array with an arbitrary length of 50. Entities will be created manually, with their IDs ranging from 0 to 50, allowing ID 'i' to point to the 'i'th element in each component's array of values. Let's take a look:

main.ts
//...

type GameWorld = {
  [key: string]: Float32Array
}

const length = 50
const world: GameWorld = {
  PositionY: new Float32Array(length),
  Speed: new Float32Array(length)
}

type Entity = number

export const player: Entity = 0
world.PositionY[player] = 20
world.Speed[player] = 7

export const enemy: Entity = 1
world.PositionY[enemy] = 14
world.Speed[enemy] = 3

//...

(Note: In practice, we'd aim to match each array type as closely as possible to a component's range of valid values. For example, if the component "ControlsUp" only accepts values {1,0} for true and false, respectively, we'd use a Uint8 array instead of a Float64 array.)

This is an extremely basic method for assigning values to entities for specific components. However, we encounter another problem: to work with entities associated with "Speed," for instance, we must loop through the entire array of values. Furthermore, we currently have no way of knowing if the 'i'th entity possesses the 'j'th component. For example, if we have a stationary entity that doesn't move in the world, it implies that it lacks "Speed." We have no way of knowing that “Speed” doesn’t apply to this entity, costing us an extra computation in the “Movement” system where we loop through and update all entity speeds.

To solve this we need a way of querying the entities that have a given component. Querying entities is a broad topic and implemented differently in various ECS engines. In our simple example, we can address this by programmatically creating entities and components in a way that adds an entity's ID to a sparse set of values for each applicable component. To start, we can create a 'createComponent' function that adds the component to our world, creates its array of values, adds an empty array for entity IDs, and returns a pointer to the component. We can then programmatically create our components:

main.ts
//...

type Entity = number
type Component = Float32Array & { eids: Entity[] }
type gameworld = {
  [key: symbol]: Component
  //...global state type defs (eg. canvas context)
}

const world: gameworld = {
  //...initailize global state (eg. canvas context)
}

function createComponent(): Component {
  const key = Symbol() 
  //add the values array
  world[key] = new Float32Array(50) as Component 
  
  //sparse array of entity ids
  world[key].eids = new Array(0) 
  
  return world[key] //return pointer to component
}

export const Component = {
  PositionY: createComponent(),
  Speed: createComponent(),
  //...other components
}

export const player: Entity = 0
Component.PositionY[player] = 20
Component.Speed[player] = 7

export const enemy: Entity = 1
Component.PositionY[enemy] = 13
Component.Speed[enemy] = 3

//...

Now we can introduce a 'createEntity' function that accepts an array of components, generates a unique ID by incrementing a global value in our game world, adds the ID to each component’s sparse set, and returns the ID. Additionally, for each entity type (player, enemy, etc.), we write an initializer function that: calls 'createEntity' generating an entity ID and associating the relevant components; sets initial component values; and returns the entity ID:

main.ts
//...

type Entity = number
type Component = Float32Array & { eids: Entity[] }
type GameWorld = {
  [key: symbol]: Component
  currentEid: number
  //...global state type defs (eg. canvas context)
}

const world: GameWorld = {
  currentEid: 0, //initialize first entity id 
  //...initialize global state (eg. canvas context)
}

function createComponent(): Component {
  const key = Symbol()
  world[key] = new Float32Array(50) as Component
  world[key].eids = new Array(0)
  return world[key]
}

export const Component = {
  PositionY: createComponent(),
  Speed: createComponent(),
  //...other components
}

export function createEntity(
  c: Component[],
  w: GameWorld
): Entity {
  const eid = w.currentEid++
  for (const component of c) {
    component.eids = [...component.eids, eid]
  }
  
  return eid
}

type EntInitializer = (w: GameWorld) => Entity

export const createPlayer: EntInitializer = (w) => {
  const eid = createEntity([
    Component.PositionY,
    Component.Speed
    //...other player components
  ], w)
  Component.PositionY[eid] = 20
  Component.Speed[eid] = 7

  return eid
}

export const createEnemy: EntInitializer = (w) => {
  const eid = createEntity([
    Component.PositionY,
    Component.Speed
    //...other enemy components
  ], w)
  Component.PositionY[eid] = 14
  Component.Speed[eid] = 3

  return eid
}

//...other game entity initializers

//...

Now, in our systems, we can efficiently query and operate on entities associated with a specific component. For example, in our 'movement' system, we can query and operate on all entities possessing the "Speed" component:

systems.ts
import type { GameWorld, Entity } from './main'
import {
  Component as C,
  createEnemy,
  createPlayer
} from "./main"

type System = (w: GameWorld) => GameWorld

export const movement: System = (w) => {
  C.Speed.eids.forEach((eid: Entity) => {
    //update postions based on current speed
  })

  return w
}

//...other systems

We're nearly finished, but there's one remaining issue in our use case. If an enemy entity requires different movement functionality than a player entity, how do we distinguish between them?

Tags

This is where 'tags' come into play. A tag is essentially a component without values, associated with an entity. We can write a 'createTag' function that adds a tag equal to an empty set of entity IDs to our ‘world’ object. And while we’re at it, we can create the tags "Player" and "Enemy", and assign them to the EIDSs in 'createPlayer' and 'createEnemy,' respectively:

main.ts
//...

type Tag = Set<Entity>

//...

type GameWorld = {
  [key: symbol]: Component | Tag
  currentEid: number
  //...global state type defs (eg. canvas context)
}

//...

function createTag(): Tag {
  const key = Symbol()
  world[key] = new Set()
  return world[key]
}

export const Tag = {
  Player: createTag(),
  Enemy: createTag(),
}

//...

export const createPlayer: EntInitializer = (w) => {
  const eid = createEntity([
    Component.PositionY,
    Component.Speed,
    //...other player components
  ], w)
  Tag.Player.add(eid)
  Component.PositionY[eid] = 20
  Component.Speed[eid] = 7

  return eid
}

export const createEnemy: EntInitializer = (w) => {
  const eid = createEntity([
    Component.PositionY,
    Component.Speed,
    //...other enemy components
  ], w)
  Tag.Enemy.add(eid)
  Component.PositionY[eid] = 14
  Component.Speed[eid] = 3

  return eid
}

//...other game entity initializers

//...

Great! At this point, we have a functional ECS implementation. Let's examine the overall structure by removing the game specific properties and functionality:

main.ts
import { render } from './render'

type Entity = number
type Component = Float32Array & { eids: Entity[] }
type Tag = Set<Entity>
type GameWorld = {
  [key: symbol]: Component | Tag
  currentEid: number
  //...global state type defs (eg. canvas context)
}

const world: GameWorld = {
  currentEid: 0,
  //...initialize global state (eg. canvas context)
}

function createComponent(): Component {
  const key = Symbol()
  world[key] = new Float32Array(50) as Component
  world[key].eids = new Array(0)
  return world[key]
}

const Component = {
  i: createComponent(),
  //...other components
}

function createTag(): Tag {
  const key = Symbol()
  world[key] = new Set()
  return world[key]
}

const Tag = {
  i: createTag(),
  //...other tags
}

function createEntity(
  c: Component[],
  w: GameWorld
): Entity {
  const eid = w.currentEid++
  for (const component of c) {
    component.eids = [...component.eids, eid]
  }

  return eid
}

type EntInitializer = (w: GameWorld) => Entity

const entity: EntInitializer = (w) => {
  const eid = createEntity([
    Component.i
    //...other player components
  ], w)
  Tag.i.add[eid]
  //...other tags

  Component.i[eid] = 0
  //...other initial values

  return eid
}

type System = (w: GameWorld) => GameWorld

const categoryi: System = (w) => {
  Component.i.eids.forEach((eid: Entity) => {
    //...update game state
  })

  return w
}

function pipe(
  init: GameWorld,
  ...funcs: Function[]
): GameWorld {
  return funcs.reduce((acc, func) => func(acc), init);
}

function game(w: GameWorld): GameWorld {

  const next = pipe(
    w,
    categoryi,
    //...other systems
  )

  render(next)

  return next
}

const compose = (w: GameWorld) => {
  setTimeout(() => {
    compose(game(w))
  }, 17)
}

compose(game(world))

As you can see, this is a highly modular approach to structuring a project and managing state that undergoes frequent changes. To view the completed example, please check out the repo .

I hope this exercise has provided you a high-level intuition for the ECS design pattern. If you're still not sold, I encourage you to try building something using this paradigm. The benefits in terms of extensibility and performance are well-documented in the game industry. However, experiencing it firsthand and becoming accustomed to it is essential to truly appreciate its beauty.