Mykola Morozov · 5 min read · Mar 1, 2024 ·

Reinventing the Wheel with ECS

Recently, I've read an article from Kasey Speakman on Entity Component System (ECS) design. It went into detail on the similarities and differences between the ECS architecture and Structured Programming. There were other great articles on the topic, describing basic approaches of using ECS and showing various use cases.

As I work at the chair of Data Science and Engineering at TUM, I tend to have a more data-oriented look on systems anyway, so I wanted to write up a small piece on using ECS from my perspective and give a beginner example of a game following an ECS architecture.

What is ECS?

The articles above go into more details. But ECS can be summarised as:

  • Entities are unique identifiers.
  • Components are atomic pieces of data that are attached to the entities.
  • Systems are functions that operate on specific components.

The ECS architecture solves the problem of game data processing by replacing traditional Object-Oriented Programming (OOP) inheritance with composition. Is ECS a better solution? The answer is somewhere between kinda and depends.

Should we move to ECS?

Modern game engines already utilize architecture elements, similar to ECS, in their design. In his Godot article Juan Linietsky goes over the reasoning behind using trees and Nodes with OOP instead of ECS. I drew the following conclusions from it:

  1. Godot inheritance using Nodes is not as expensive, since the default "components" are typically added to most entities anyway.
  2. Godot uses efficient "servers" that streamline processing data of particular kinds to e.g. render 3D models (RenderingServer) or calculate physics (PhysicsServer3D).

Unity has a similar approach, where the engine aggregates Renderer components and sends them to the Render Pipeline.

Thus, it is not a strict necessity to use ECS, and other efficient methods can be utilized. Still, I'd like to share my experience of using ECS as I hope it will help someone achieve better understanding of the topic.

Example

When I design ECS parts in my pet projects, I always try to imagine creating a normalized database instead of the game. Since SQL is Turing-complete according to Dfetter, Breinbaas, having the data-related game elements designed as relational database structures makes it easy to transition to an ECS design.

Let's design a simple Pong game.

Please, enable JavaScript for JS activation.

First, let's design a relational database for the game. The relations represent ECS components. Each relation record in an ECS component instance. The unique keys are ECS entities. Stored procedures that operate on data are ECS systems.

Database relations diagram.

The IsRacket and IsBall components are "empty" and represent flags/tags. The Position and Velocity contain respective location and speed attributes. The id attribute inside the Entity relation is a primary key and corresponds to the entities present in the ECS model. The id attributes in other relations are foreign keys. Now the corresponding PostgreSQL code:

CREATE TABLE Entity(id INTEGER PRIMARY KEY);
CREATE TABLE IsRacket(id INTEGER, CONSTRAINT u_isracket_id UNIQUE(id), CONSTRAINT fk_isracket_id FOREIGN KEY(id) REFERENCES Entity(id));
CREATE TABLE IsBall(id INTEGER, CONSTRAINT u_isball_id UNIQUE(id), CONSTRAINT fk_isball_id FOREIGN KEY(id) REFERENCES Entity(id));
CREATE TABLE Position(id INTEGER, x REAL, y REAL, CONSTRAINT u_position_id UNIQUE(id), CONSTRAINT fk_position_id FOREIGN KEY(id) REFERENCES Entity(id));
CREATE TABLE Velocity(id INTEGER, vx REAL, vy REAL, CONSTRAINT u_velocity_id UNIQUE(id), CONSTRAINT fk_velocity_id FOREIGN KEY(id) REFERENCES Entity(id));

Now, we should create the stored procedures that implement the logic of the game. First, a function that moves the ball. Let's assume the playing field to be 512x256 in size, ball to be 10x10, racket to be 10x50 and 5 units off the wall. There's a 5-unit-wide wall on the right side. This function just bounces the ball off all 4 walls.

CREATE OR REPLACE FUNCTION UpdatePosition(deltaTime REAL)
RETURNS VOID
AS $$
BEGIN
    UPDATE Position SET x=x+Velocity.vx*deltaTime, y=y+Velocity.vy*deltaTime
    FROM Velocity
    WHERE Position.id=Velocity.id;

    UPDATE Velocity SET vx=-ABS(vx)
    FROM Position
    WHERE Position.x>=502 AND Velocity.id=Position.id;

    UPDATE Velocity SET vx=ABS(vx)
    FROM Position
    WHERE Position.x<=20 AND Velocity.id=Position.id;

    UPDATE Velocity SET vy=-ABS(vy)
    FROM Position
    WHERE Position.y>=251 AND Velocity.id=Position.id;

    UPDATE Velocity SET vy=ABS(vy)
    FROM Position
    WHERE Position.y<=5 AND Velocity.id=Position.id;
END;
$$
LANGUAGE plpgsql;

Next, create a function that updates the racket to always perfectly hit the ball. We target the ball with the highest position aka maximum y, assuming at least one ball exists.

CREATE OR REPLACE FUNCTION UpdateRacket()
RETURNS VOID
AS $$
UPDATE Position SET y=MIN(MAX(t.y - 25, 0.0), 206)
FROM (
	SELECT pos.id as id, (SELECT MIN(p.y) FROM Position p INNER JOIN IsBall b ON p.id=b.id) as y
	FROM Position pos INNER JOIN IsRacket r ON pos.id=r.id
) as t
WHERE Position.id=t.id
$$
LANGUAGE sql;

Finally, you just need to seed the values and call the two update functions several times a second. Unfortunately, for loops can only exist inside stored

DO
$do$
BEGIN
    INSERT INTO Entity VALUES (0),(1);
    INSERT INTO IsRacket VALUES (0);
    INSERT INTO IsBall VALUES (1);
    INSERT INTO Position VALUES (0,5,0),(1,25,25);
    INSERT INTO Velocity VALUES (1,400,260);

	FOR counter IN 1..60 LOOP
		PERFORM UpdatePosition(1.0 / 30.0);
		PERFORM UpdateRacket();
	END LOOP;
END
$do$;

You can see the result translated into JavaScript at the top of the page. The JavaScript code for it can be found in the source repo for the website on GitHub or in the code of this blog page (hit F12).

This is a very rudimentary example, but the same approach would work with more complex applications. To be clear, I strongly discourage designing systems in other languages around their SQL implementation, this article is just a basic intro to ECSxSQL, but this approach helps me set my mind on ECS rails much faster.