Entity Component System in TypeScript

Profile pictureToni Petrina
Published on 2020-05-263 min read
  • #javascript
  • #typescript
  • #ecs
  • #gamedev

Games can be architected in different ways, but Entity Component System, also known as ECS, is one of the more popular ones. I won't spend too much time explaining why one should pick ECS over OOP or any other style, there are better resources out there.

In this series I will document my experience building ECS in TypeScript and why I did what I did.

To begin, we need a Component:

// All components must identify themselves.
type Component = {
  type: string;
};

Entities are composed of components and merely identifiers. However, I want to start with something easier:

type Entity = {
  id: number;
  components: Component[];
};

let nextId = 1;
function createEntity(...components: Component[]) {
  return {
    id: nextId++,
    components,
  };
}

So far, so good.

It's important that components carry state and there should be no logic in either components or entities. So where is the logic? Inside Systems.

System will receive a list of entities and process them in some way. They should have no state and merely operate on components/entities. They can read them, update components, create or remove components or even whole entities.

The simplest abstraction is as follows:

// something we need to supply every frame
// can be current time, current frame number, both or more
type TickInfo = number;

type System = {
  update: (tickInfo: TickInfo, entities: Entity[]) => void;
};

And that's it! Let's build something with this!

An example

The first useful component we can think of is position:

class PositionComponent implements Component {
  type = 'position';
  constructor(public x: number, public y: number) {}
}

// easy way of creating position
const position = new PositionComponent(10, 10);

So how would we render it? Our game loop should be rather simple. Given a list of systems we call update on every one of them per frame.

const systems: System[] = [];
const entities: Entity[] = [];

function render() {
  const now = new Date().getTime();

  systems.forEach((system) => system.update(now, entities));

  requestAnimationFrame(render);
}

render();

Let's create a simple rendering system:

function createRenderSystem(): System {
  const canvas = document.createElement('canvas');
  document.body.appendChild(canvas);
  const ctx = canvas.getContext('2d');

  return {
    update: () => {
      ctx.save();
      ctx.setTransform(1, 0, 0, 1, 0, 0);
      ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
      ctx.restore();

      entities.forEach((e) => {
        // so how do we get the rendering data
      });
    },
  };
}

So...given a list of entities we only care about a few select components. Let's introduce a new helper function: getComponent:

function getComponent<T extends Component>(e: Entity, type: string) {
  return e.components.find((c) => c.type === type) as T;
}

Now we can finish our render system:

entities.forEach((e) => {
  const p = getComponent(e);
  ctx.fillRect(p.x, p.y, 1, 1);
});

Running this code yields a black rectangle on screen. Nothing exciting yet.

Source code for this code can be found at https://github.com/tpetrina/ecs-ts-test/blob/master/examples/ecs1.ts and live demo at https://ecs-ts-test.netlify.app.


Change code theme: