Entity Component System in TypeScript
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 System
s.
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.