Introduction
When I shared this example, it got some attention and serving as “learning project” to see that is a ECS. But the several things in example are controversial and not very good to use in the real project. So I want to make a post about this example and how I want to improve it.
This ECS examples, as I said before, used the “entities” approach. It’s a single concept I used to make this example. I think that less core concepts it’s much easier to understand. But a lot of entities (used for function calls and events) is not very good for performance and memory. We are in Lua, so we need to be careful with this.
By concepts I mean the ideas and rules how the game works. In “entities as command and events” only one concept - use entity for everything.
Let’s dive with me to my yesterday tiny journey with refactoring system_commands
and system_events
(what it is read the forum topic or README of GitHub)
In next steps we will remove the system_event
completely. Refactor the system_command
and make it much more comfortable to work with. Let’s go!
The system_event
section
The system_event
concept
Currently, for each event, like transform changes, we need to create a new entity with a transform_event
component. This entity will be processed in the next frame by the all systems, which are interested in this event. This approach has several issues:
- Each system that throws event described with a separate file
system_event
.
- Each entity contains at least from two tables, so at least ~80 bytes per entity. The amount of entities can be huge!
- All events will be delivered as is, we can’t easily preprocess them
- The
system_command
who listen events, can grows in a system filter, what not looks good:
system.filter = ecs.requireAny("camera_command", "window_event", "transform_event")
- A lot of lines of code (5!) to spawn the event entity
---@type component.transform_event
local transform_event = {
entity = entity,
is_position_changed = true,
}
self.world:addEntity({ transform_event = transform_event })
Why do I use this? I think it was easier to understand without implementing anything new. So the list of pros in this case:
- Easy to understand how events delivered (well, like any other entities)
- Uses the same flow of entities, no any additional “concepts”
Event Bus Concept and Implementation
In many articles and frameworks ECS has a “event bus”, so let’s dive into this concept and try to imagine what we want to achieve:
- We want to deliver events controllable, not immediately
- Want to deliver events to each relative system
- We want to process events in some way. Under process - merging with others, changing the event, etc
- We want it make much performance and memory friendly (it’s not so hard to achieve, we spawn a two tables per event now!)
So we want to have a kind of list of events, which are aggregated until the next frame and then been processed by the systems.
All systems has access to world and let’s try to expand our world with a queue
object
---@class world
---@field queue queue
self.world = ecs.world()
self.world.queue = queue.create()
What queue should to do, let’s define their idea:
- To trigger event, the system should call like this.
self.world.queue:push("transform_event", { entity = entity, is_position_changed = true })
- These events should be accessible after all systems updated
- We can add a “internal” system to manage this queue if required
- We want to achieve easy way to subscribe (ideally just processing events) in the systems
Let’s see on tiny-ecs
and what a lifecycle of update:
- Manage Systems: call
onAddToWorld
/onRemoveFromWorld
- Manage Entities: call
onAdd
/onRemove
- Each system call
preWrap
- Each system call
update
- Each system call
postWrap
We can try to proceed events in the postWrap steps, directly in this frame after all systems are updated. If additional events are produced in this step, they will be postponed to the next frame.
Let’s add a process
function to the postWrap step in the game_object_system
-- This postWrap functons called once per system right after the update
function M:postWrap()
self.world.queue:process("transform_event", self.process_transform_event, self)
end
This should replace next code in the same system:
-- System Filter
system.filter = ecs.requireAny("game_object_command", "transform_event")
-- onAdd...
---@param entity entity.game_object_command|entity.transform_event
function M:onAdd(entity)
--- ...
local transform_event = entity.transform_event
if transform_event and self.game_object.indices[transform_event.entity] then
self:process_transform_event(transform_event)
end
--- ...
end
To make this works, we need to “upload” all cached events to the actual list of events to process. So add the small “internal” system to manage the queue. It should be first in our systems list to make sure all events are ready to process in the beginning postWrap step.
local ecs = require("decore.ecs") -- this is tiny-ecs
---@class system.queue: system
local M = {}
---@return system.queue
function M.create_system()
return setmetatable(ecs.system(), { __index = M })
end
function M:postWrap()
self.world.queue:stash_to_events() -- I'm also a champion of naming, if you didn't know
end
return M
Oh, nice! We actually solved the one of the issue with frame delays, so events chains now processed faster! The first event in “event-chain” will be processed in the same frame. This one is a good improvement!
Also, now we don’t need any of system_event
files, since we can trigger events and subscribe on them directly from the systems code.
Happy to delete a lot of files
Event Annotations
In previous, we had a whole file to describe the event params. Let’s move this annotations to the main system file.
This is how looks the health
system description with event annotations it produces.
-- health_system.lua
---@class entity
---@field health component.health|nil
---@class entity.health: entity
---@field health component.health
---@class component.health
---@field health number
---@field current_health number|nil
---@class event.health_event
---@field entity entity.health
---@field damage number
We can use it in the place we subscribe to the events:
-- death_system.lua
function M:postWrap()
self.world.queue:process("health_event", self.process_health_event, self)
end
---@param health_event event.health_event
function M:process_health_event(health_event)
local entity = health_event.entity
if entity.health.current_health == 0 then
self.world:removeEntity(entity)
end
end
Merging Events
Let’s think more about events. What happend, when 20 explosions will hit the enemy in one frame? In “entities based” and “queue based” approaches the 20 health_event
will be produced. For each events the damage_number
system will spawn a new entity. Probably not good. Can we make something with it?
In this case, I want to merge all damage events it produced per frame for one entity. So let’s create a function to hook the event bus and see, if we can merge it with the other events.
I created a set_merge_policy
function, which takes the event type and the function to merge the current events list for this event. This function should return true
if the event was merged and shouldn’t be added to the queue, and false
if the event wasn’t merged and should be added as before.
So in case of health_event
we can set it like this:
--- health_system.lua
function M:onAddToWorld()
self.world.queue:set_merge_policy("health_event", self.merge_policy)
end
---@param events event.health_event[] Current events for the event_type
---@param event event.health_event New event to add to the queue
function M.merge_policy(events, event)
for index = #events, 1, -1 do
local compare_event = events[index]
if compare_event.entity == event.entity then
compare_event.damage = compare_event.damage + event.damage
return true
end
end
return false
end
The same thing I did to transform_event
to merge all entity events.
Summary
We removed the system_event
and replaced it with the event_bus
concept. This concept allows us to control the events and process them in the same frame. We also added the ability to merge events to reduce the amount of entities in the system. This approach is much more performance and memory friendly.
Here is a PR with a diffs to the original project with this changes: Example 1: Replace for `system_event` by Insality · Pull Request #1 · Insality/shooting_circles · GitHub
Will leave this PR open as an example for this block.
The system_command
section
The system_command
concept
The system_command
was created to make an “external API” for the systems. We want to have a way to call the system calls from the code, but the current approach is not very good. Let’s see on the example from the Shooting Circles:
---@type component.health_command
local command = {
entity = other,
damage = on_collision_damage.damage
}
self.world:addEntity({ health_command = command })
Like with events
, we create a new entity with a health_command
component to make a call to the health system. And also like in the events
approach, we have a lot of issues with this:
- We need to create a new entity for each call
- A lot of lines of code to spawn the command entity (5 again!)
- We can’t easily inspect the code and go to the definition of the command
- The place where commands handled is not very clean in code
And also again with the events
, it was leaved as is due it’s the absolutly the same concept with entity, no anything new here. Oh, and also we can use this commands directly in level editor (like in Tiled) to make some actions in the game (this is still just a data!)
So, let’s try to refactor this to make it much more easier to use! And make some improvements to the current approach. Coding time again!
The system_command
refactoring
First, write a things we want to achieve with this refactoring:
- We want to inspect the code and go to the definition of the command. The code reading speed is everything.
- We want to call the system calls from the code and from the data. The data can be a level editor or some external source.
- We want to have a loose coupling with the systems. The system should be easily refactored without required to check all other systems or files.
- We want to have a much cleaner code.
As before, the only global accessible thing in systems is the world
object. So let’s try to add a new field to the world to store the system command. This will allow us to call the system command directly from the systems like this:
self.world.health_command:apply_damage(other, damage)
Should be nicer, right? Not this scary messages. Let’s try to implement this!
Let’s open health_command_system
and add the annotations and registering itself to the world to make it accessible from the other systems.
---@class world
---@field health_command system.health_command
---@private
function M:onAddToWorld()
self.world.health_command = self
end
---@private
function M:onRemoveFromWorld()
self.world.health_command = nil
end
The lua annotations allow us to extend the already existing class. So add new field to the world class and also register the self system to the world (you don’t want to pprint this world
now)
Oh, is it all? Can we call it already to process functions? Almost. Before we have handling messages in onAdd
functions to check the incoming entities. And now we to use it instead of entities command. For example instead of this construction
---@param entity entity.health_command
function M:onAdd(entity)
local command = entity.health_command
if command then
self:process_command(command)
self.world:removeEntity(entity)
end
end
---@param command component.health_command
function M:process_command(command)
if command.damage then
self.health:apply_damage(command.entity, command.damage)
end
end
convert to this:
---@param entity entity
---@param damage number
function M:apply_damage(entity, damage)
assert(entity.health, "Entity does not have a health_command component.")
-- Note: My system_commands has a link to the health system
self.health:apply_damage(entity, damage)
end
Awesome! And this is so simple. Why I didn’t do this before?
Any person asks themselves this question from time to time
Call system command from level editor
Previously, I had an entity with on_spawn_command
component. This component add the command with on_spawn_command
field. I used it for each level to set the GUI text in the game GUI. It was this json:
"command":{"gui_main_command": {"level_complete": true}}
But several seconds ago we got rid of the system_command
entities approach and this command got broken! We need to update it to make it works again. Apparently, we can use just array of strings to find the exactlly function to call. So the new json will be like this:
"command":["gui_main_command", "set_level_complete", true]
And wohoo! Now the call from the level editor is much more easier. This is a good improvement!
Summary
So time to replace all our “system_command entities” to the direct calls. Time to clean up code and make it more readable and understandable.
The system_command will not more required a system filters
and even the onAdd
function to handle incoming entities. We can get rid of this, nice. So the system_command stays a minimal set of functions that we allow to users to call from the code. The system itself begin untouchable and can be refactored easily without required to check all other systems or files. Congratulations with the a small win!
Here is a PR with a diffs to the original project with this changes: Example 2: Refactor `system_commands` by Insality · Pull Request #2 · Insality/shooting_circles · GitHub
Will leave this PR open as an example for this section.
Code CleanUp
We refactored 2/3 of initial system concepts! Now we need to go through all systems and update them to use the new approach.
The camera system become easily accessible from the other systems, and the call to camera.screen_to_world
now is not tricky as before via global system module.
A lot of lua annotations and commands replaced, so the code now is much more readable! Now systems mostly contains one file and this is how it should be as I think. Still no external requirements, self-registration to world and easy to refactor.
So that with performance? Let’s test it.
The old approach
5-7 megabytes per second while shooting in “hellgun” example.
uuhhh…
The new approach
2-6 megabytes(!) per second while shooting in “hellgun” example.
What? Well, it’s definitely better! But as we see, the memory consumtion is still seems big. I expected to reduce it to much more with new approach without entities as events and command. But if think, this ~2 megabytes in seconds can be about (2 * 1024 * 1024 / 40) 51 200 lua tables! In this numbers it’s a lot!
But seems the issue with the memory consumption is not in the events and commands. Seems this is a question for another huge section!
By the way, the PR with this cleanup changes is here: Example 3: Clean Up Code by Insality · Pull Request #3 · Insality/shooting_circles · GitHub
Memory Consumption Detective
Let’s start. I’m pretty sure from my tests before it’s because of physics, collistion or box2d. Will try to find issues in this area.
Will write a numbers I observed in the game:
- Nothing: +100kb/s
- Moving: +200kb/s
- Shooting in wall without explosions: +400kb/s
- Shooting in wall with explosions: +450kb/s
- Arcanoid: bouncing balls between walls: +700kb/s
- Arcanoid: bouncing balls to enemies: +7000kb/s khm…
So it should be collisions? Let’s try to disable the collisions system and see what happens.
- Arcanoid: bouncing balls to enemies: +1500kb/s
So, the collisions generate a terrible amount of memory.
go to look
Strange, even with this the memory consumtion the same…
physics.set_listener(function(_, event_id, event)
--M.physics_world_listener(self, event_id, event)
end)
go to look
But this returns consumption to the +1500kb/s. Seems all the collision events from physics engine now sends to the lua side directly.
--physics.set_listener(function(_, event_id, event)
--M.physics_world_listener(self, event_id, event)
--end)
We can’t affect on physics engine from our lua code, so leave it as is. Let’s make our initial memory consumtion test again to see a bit clearer results.
To make this, I’ll disable physics listener to test again initial version of Shooting Circles and with the new approach.
The old approach
+3900kb/s while shooting in “hellgun” example.
The new approach
+2900kb/s while shooting in “hellgun” example.
Well, okay, megabyte is still a good result (26k tables per second!). But the new approach is much better to use and read. So I think it’s a good result!
All other memory spikes what we are investigating related probably to the physics engine or other system’s code where you create vectors with go.get_position
and other functions. At least we see a expected difference between the old and new approach.
Ending
Hey, it was a pretty fun to write this! Thanks for the reading! Not sure about this style post, is it interested to read? Share your thoughts!
Cheers
And the total changes of this “small night journey” is here: Example 4: All Changes by Insality · Pull Request #4 · Insality/shooting_circles · GitHub