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.