Rétro-Actif

Rétro, blog, prog.

Writing an ECS engine from scratch

-

(Note : Désolé pour les francophones, ce billet a pour objectif premier d'être sur un autre site, en anglais.)

Entity Component System is an interesting pattern. Instead of thinking a game entity as having data and logic, an ECS pushes you to move instead separate logic from data. Think as logic as a factory processing some raw material to craft an object.

There are a lot of writing about ECS theory and conception, from vague explanation to explain the reasons behind storing entities in contiguous memory (we'll get to that term later). This is not intented to be a trustable reference but just some log and notes from someone who tried from vague explanations found on Internet to build a full ECS system in Lua for initially Love2D.

Why this choice?

The biggest reason behind this move was experimentation. I'm currently facing issues with the structures of the game engine I build or used and wanted to explore different options, relating with my targeted professional work (I'm aiming to become game engine developper). I heard mostly good feedback about ECS and I wanted to delve into them to understand the advantages and the problems that raise from adopting such architecture.

Also, I'm still on the game engine structure discovery adventure. I'm still trying to work on my Pico-8 experiment with couroutine-base engine (where update and render of each entity are coroutines, allowing for delayed procedures as if it was a simple list). I liked building this light system (with a sample CPU usage graph entity, it weights around 202 tokens) that allowed me even to break down global game state as entities (the splash screen itself is an entity).

An ECS allows data and logic separation through Components and Systems. A component is just made of data (like Vector would only be comprised of its coordinates) where a system is just holding the data (like VectorRenderer which would for instance draw a vector on the screen). This allows for components or systems to be reused at multiple places and pushed into a more generic or structure conception process.

Design choices

I wanted to build a system that :

  • Allowed me to create entities just by listing a few components and/or their arguments
  • Trying to store contiguously components to allow, the best I could on lua, streamlining data to get the most of CPU caches.
  • Light and fast enough to not be limiting an user's choices around design and organisation.

Conception

I rediscovered Love2D by discussing with people about Pico-8. I decided to choose this game engine as it gives a directly usable framework to play with with the classic update/render loop. Plugging the engine on it will be easy. As a part of a bigger engine idea, the ECS will be stored in a World (here ECSWorld, I might be swapping both names, sorry) that'll be called at every update or render tick next to the Quake-like debug console I also did for this experiment.

Given a class factory by a friend, the ECS system is built over those classes - Component: a simple class designed to only hold a single function init defining its own structure. - System: the base behind the systems (also called processors in some engines). They're stored and called by System Filters. - Renderable and Updatable : two base system classes to determine which components needs to be updated or rendered (called inside the world's update or render). - SystemFilter: just a small system container to group them by the list of components they need to avoid filtering the entities per component. Those classes are updated and/or called by the world.-ECSWorld` : the ECS system container holding the entity array, the system filters and the components.

And that's all. Lua is very flexible so making templates or containers aren't really needed here. That's its biggest advantage for dense code. Let's delve into the code class by class, shall we?

Entity

Entity is just a simple class that holds an UID, a boolean to know if the entity can be recycled (here I have both for another reason) and a reference to its container world for simpler access code. It's as simple as this :

1
2
3
4
5
6
7
8
9
function Entity:init(index, world)
    self.__destroyed = false
    self.__destroy_required = false
    self.__eid = index
    self.__world = world
end
-- and later in the code...
    local entity = engine.world:createEntity()
    entity:addComponent(LifeComponent, 25, 42) -- See Component section

I have two booleans to allow me to remove an entity only once the update loop is done, not on the fly but the logic stays the same, there is just an ID, an alive indicator and let's roll!

Component

The component is just supposed to be a data structure (in C++, you would call them POD), with small changes, like giving them meta-methods to return a string from them. Every Component class holds a Unique Identifier to allow sorting them and comparing them more easily, predictability and fastly than comparing them with their content or their pointer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
local LifeComponent = Component()
LifeComponent.__name = "LifeComponent" -- This is just to have a readable result when calling __tostring
function LifeComponent:init(life, life_max)
    self.life = life
    self.life_max = life_max
end
local ManaComponent = Component()
ManaComponent.__name = "ManaComponent"
function ManaComponent:init(mana, mana_max)
    self.mana = mana
    self.mana_max = mana_max
end
-- Note that we could make this less repetitive by making
-- a common class like Gauge, or Stat, but that's just an example.

System

System is a similar class. Having metamethods to give them names and an unique identifier, they'll contain instead the logic through an update function. In my engine, they're directly given the SystemFilter's registered entities (we'll get to that later) so they'll process them the way they want (processing their logic one by one or globally managing them).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
local CreatureSystem = System()
function CreatureSystem.update(dt, entities)
    for i,entity in ipairs(entities) do
        print(string.format(
[[HP : %d/%d
MP : %d/%d]],
        entity:getComponent(LifeComponent).life,
        entity:getComponent(LifeComponent).life_max,
        entity:getComponent(ManaComponent).mana,
        entity:getComponent(ManaComponent).mana_max))
    end
end

-- and later in the code
-- Registering CreatureSystem as needing LifeComponent and ManaComponent
world:registerSystem(CreatureSystem, LifeComponent, ManaComponent)

What I'm planning at the time I'm writing this note is adding callbacks to the systems at some events during entity modification : adding or removing a component should trigger related systems' callback for usages like removing a hitbox from a physics engine once its entity is stripped off the Hitbox component.

SystemFilters

SystemFilter is a class that only exists to store Systems and to register compatible entities. First, a SystemFilter is created by passing a list of Component classes to be registered as a filter, entities having an instance of all Components of a class will be registered to the filter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
--[[
    Returns true if the entity has all the components for the current filter.
]]
function SystemFilter:checkEntityCompatibility(entity)
    local num_valid_components = 0
    for i,component in ipairs(self.required_components) do
        if entity:hasComponent(component) then
            num_valid_components = num_valid_components + 1
        end
    end
    return num_valid_components == #self.required_components
end

Thus, only entities fully relating to systems are managed. This is not a perfect implementation as there is still need for a local entity list. As said earlier, such class allows some memory sparing and also allows a faster (I suppose) processing as the class registers and unregisters entities based on their component list.

Renderable and Updatable components

Those two components are directy used by SystemFilter to filter out to determine which component will be called at update time or at render time. Nothing really fancy except the fact that every component must be a child class from one of those two (or both) to be acounted by any system.

ECSWorld

ECSWorld is the class that holds everything in place and opens function such as to create an entity and call update/render loops.

My own caveats

The most important potential issue I'm facing is the impossiblity of an entity to have multiple components of the same type, blocking me from a simple structure like a sprite and multiple hitboxes on the same entities, I would have to get to make sub-entities directly linked to the main one.

Another major one is issue like graphisms rendering. Here, Love2D doesn't seem to have Z-ordering, which currently blocks me from agencing the way I like drawable elements. An idea I had is to create a drawable list and sort it by priority. The fact this issue exists raise the global issue of not being able to run the processes in a custom order. This is not supposed to be an issue, but I suppose in this case and in some edge ones, it could be.

Some smaller ones are more the public API, like the system registering which is a bit weird to have as that's the part that declares the component requirement. It would be better if I were to put this in the System declaration for instance.

More to come?

I don't know if I'll push forward with this structure. Creating everything in lua engine-wise sounds pretty silly to me after working in performance critical code, even though I might use this structure as an exercise or with small games. I tried to see where it could be bound (CPU or GPU) and it seems that for a few thousands of simple entities that would draw each one a filled rectangle, it gets pretty GPU-bound, even though I had some floating point operation at every component. I scrapped the source as I didn't realized I could use it as a sample code here. Sorry! CPU might not be a problem with a high-end computer but the thing that worried a bit for me was the memory managment. I'm not really used to see the memory usage grows over time without being able to see correctly the true memory usage without throwing a garbage collection.

Bonus!

Here's how my debug graph components and system look. They're CPU/RAM/FPS graph drawing and updating bits.

Graph

The common component and system. Graph just holds a few values and the drawing commands, GraphUpdater takes next_value and pushes it into the graph, cycling the values and GraphRenderer just draws the graph on the screen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
local Graph = Component()
Graph.__name = "Graph"
function Graph:init(x, y, graph_width, graph_height, value_function)
    self.visible = false
    self.graph_width = graph_width
    self.graph_height = graph_height
    self.values = {}
    self.values_to_draw = {}
    self.next_value = nil
    self.min = 0
    self.max = 1
    self.x = x
    self.y = y
    self.get = value_function
    for i=1,self.graph_width do
        self.values[i] = self.min
        self.values_to_draw[i*2-1] = i
        self.values_to_draw[i*2] = graph_height
    end
    self.frame = {
        0,0,
        0,self.graph_height,
        self.graph_width, self.graph_height
    }
end


local GraphUpdater = System(Updater)
GraphUpdater.__name = "GraphUpdater"
function GraphUpdater:update(dt, entities)
    for i,e in ipairs(entities) do
        local g = e:getComponent(Graph)
            if g.next_value then
                g.last_value = g.next_value
                g.next_value = nil
                for i=1,#g.values-1 do
                    g.values[i] = g.values[i+1]
                end
                g.values[#g.values] = g.last_value
                if g.last_value > g.max then g.max = g.last_value end
            end
    end
end


function GraphRenderer:update(dt, entities)
    for i,e in ipairs(entities) do
        local g = e:getComponent(Graph)
        if g.visible then
            for i=1,#g.values do
                local ratio = (g.values[i] - g.min*1.0)/(g.max - g.min*1.0)
                g.values_to_draw[i*2-1] = i
                g.values_to_draw[i*2] = g.graph_height - g.graph_height*(ratio)
            end
            love.graphics.push()
            love.graphics.setLineStyle("rough")
            -- Moving to right-down corner and making place for the frame time
            love.graphics.translate(g.x, g.y)
            love.graphics.line(g.frame)
            love.graphics.points(g.values_to_draw)
            love.graphics.print(string.format("Max : %.2f", g.max), g.graph_width + 1, 0)
            if g.min ~= 0 then
                love.graphics.print(string.format("Min : %.2f", g.min), g.graph_width + 1, g.graph_height - love.graphics.getFont():getHeight())
            end
            if g.last_value then
                love.graphics.print(string.format("%.2f", g.last_value), 0, g.graph_height + 1)
            end
            love.graphics.pop()
        end
    end
end

CPU usage Graph

CPUStat holds the stating delta time between two CPU frames to determine the time the CPU calculated stuff. CPUGraph just compares the previous value (or start) and stores the delta. GraphRenderer is used here to avoid repeat code.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
local CPUStat = Component()
CPUStat.__name = "CPUStat"
function CPUStat:init()
    self.start = love.timer.getTime()
end


local CPUGraph = System(Updater)
CPUGraph.__name = "CPUGraph"
function CPUGraph:update(dt, entities)
    for i,e in ipairs(entities) do
        local g = e:getComponent(Graph)
        local s = e:getComponent(CPUStat)
        local t = love.timer.getTime()
        g.next_value = love.timer.getTime() - (s.previous or s.start)
        s.previous = t
    end
end

Memory usage Graph

Same principle for the memory graph. I could have gone with full inheritance. I just wanted tot make things just work.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
local MemStat = Component()
MemStat.__name = "MemStat"


local MemGraph = System(Updater)
MemGraph.__name = "MemGraph"
function MemGraph:update(dt, entities)
    for i,e in ipairs(entities) do
        local g = e:getComponent(Graph)
        g.next_value = collectgarbage('count')
    end
end

Framerate graph

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
local FPSStat = Component()
FPSStat.__name = "FPSStat"
function FPSStat:init()
end


function FPSGraph:update(dt, entities)
    for i,e in ipairs(entities) do
        local g = e:getComponent(Graph)
        g.next_value = love.timer.getFPS()
    end
end

Tags