Shooting Circles - Defold ECS game example

logo

Hello! I created a game example using the ECS (Entity Component System) architecture. It took several iterations, and now I want to share the result.

In case you missed my post in Community Challenge: Explosions, I’m sharing this as a separate forum thread.

The GitHub README contains several notes about how the game and architecture work. I followed these main rules for the project:

  • Use the tiny-ecs library without modifications.
  • Entities should be simple Lua tables without any special data.
  • No external require() calls in systems to maintain portability between projects.
  • Components can only be modified within one system. For example, entity.transform.position_x = 10 should only be changed in the transform system.

I think this might be interesting for programmers. If you have different approaches, ideas, or questions, let’s talk!

There are still some areas where it’s unclear how to do things the “correct” way (though in real games, we often cut corners because it’s faster and easier), such as event handling, debugging, and correct composing different systems, but I think this is a good start!

GitHub: https://github.com/Insality/shooting_circles
Play HTML: https://insality.github.io/shooting_circles

18 Likes

One interesting point might be how to handle the Defold GUI in ECS. In this project, I used the following approach:

  • The GUI collection contains only the GUI element. This collection can be loaded as a bootstrap collection, allowing the GUI elements to be built independently of the game. Once the game is ready, you can connect it to the GUI though their “bindings”.
  • The GUI script creates a set of “bindings” (I use defold-event to enable cross-context function calls). These “bindings” are stored in a lua module, indexed by the game object’s url key (acquired via msg.url()).
  • The GUI is an entity with a “game_object” component and a specific component for the GUI.
  • When the game_object is created, the GUI system can acquire the GUI bindings through the game object’s URL and subscribe to or call bindings to set up the GUI state.

This approach allows to switch or replace logic or visuals separately from each other, which is cool!

I also have an simple diagram how it works here:

8 Likes

The tiny-ecs library is quite popular here, likely because it’s very easy to use and allows for flexible implementation. The @dlannan made an interesting topic about it: Flix Procedural Training and Movie Making.

There is a lot of discussion on the web about ECS, saying that additional tools are more crucial for this approach if compared to others.

An ECS admin panel can be a powerful tool. Since this type of architecture allows you to inspect and modify entities and systems easily at runtime, having a set of instruments to operate with it would be very cool!

9 Likes

@Insality thank you very much for this! :heart:
How is your experience with ECS? Is it convenient? How is the development looking like while using it?

Not much honestly.

I made 4 prototypes on tiny-ecs until build the “add-content” flow (you all know that I’m about :upside_down_face:). Tested different approaches with various things like saving, network, level design, UI, available tools, how iterate over balance and levels. And looking how in general the development flow is going with that. It’s pretty fun way to understand tech by trying it in various cases!

Hard to compare about convenient, cause all have different development flow. It was pretty hard to me start thinking in fully data driven component way, like on this shooting circles. But what it seems to me (from code side):

  • Most systems will be equal between your projects
  • Annotations for Lua works good in this way
  • For most feature/task for your project you will have only one way to start doing this. Feels like low friction before start implementing anything in the game. About one way I mean it can be even “step by step” flow to add instruction to yourself or your team members.
  • System’s code are pretty easy to understand and debug due it’s mostly standalone file logic.
  • More debug tools will be required as you grow. To see kind of chain of events, different collisions and leaks.
  • Saving process was different in my case. I had like “save_transform” or “save_field_state” components and each corresponding system save/load this component separately (they was linked by tiled_id field from level editor)
  • With Tiled the content design are linked with code development. I usually place data as I wish to design in this game and going to write systems to make this level work. From some time it feels like “game first” and not “code first” way to do game.

Also it’s hard to stay in this ECS rules. And probably not required and each should modify them. And sure use this as a tool in your toolset for games.

2 Likes

It can be quite useful :slight_smile: The tiny-ecs repo I shared uses the same original tiny-ecs with a single modification (added a system counter for calls - so I could check systems call count during operation). The server can hook straight into pretty much any tiny-ecs setup. I will add docs on how to do this.

Love the little exploding game too. Its very nice.

Some things Ive found with tiny-ecs (and ecs in general too) are that they arent really ideal for very large entity counts. Up to about 100,000 is pretty good. After that, lua’s hash hurts it. Thats were I would move to a more graph oriented system (like the opensteer proximity database).
Also, if you have many systems it really needs some load balancing and sequencing, but that is a fairly small use case set. Overall its great for building game systems quickly, that need entities to be managed in some way.

if you are interested in the repo for the server + tiny-ecs its here:
https://forum.defold.com/t/tiny-ecs-http-server/77253
Sorry to cross post, not sure if you had seen the topic.
Thanks for sharing!

2 Likes

Yeah, sorry! Forgot about this thread. Thanks for linking it here.

It’s true that doing things via update in each system are quite resource-consuming in Lua. One of option to resolve this is using more event-way approach to react in on_add instead of update (like in this shooting circle example). This can help optimize large cases, though it comes with some other costs.

From the other side I think acting in update is enough for a lot of games and can be faster to develop

1 Like

Agreed. Although I would be wary of events too. The hash maps (tables) are easy to make very large and start impacting your work. Additionally tables will rebuild their indexes on runtime inserts and during other changes. Its brilliantly fast for reading, but can be messy for writing. I tend to recommend “pre-indexing” all your hashmaps before you get to run loop, then you can assign and read at very high speed :slight_smile:

There is also a little trick in lua too. If you use lua as arrays (assign key as an integer or declare a list of comma separated initializers) then it will store as arrays and can be much more performant on large scales - it does millions quite well.

There are some good docs about this (specifically luajit) that can help if needed for perf if you run into it. Lua can be made to work extremely fast (C like) if care is taken :wink:

Note: I tend to always leave these sort of optimizations to a post process. Its nice to have to have a way to really ramp up perf when you prepping for a release :slight_smile:

Again, really love your work too (not just this your games and such). Thanks again. Really should have made a 3D explosion for the challenge.

3 Likes

A post was split to a new topic: Data Oriented Programming

Sounds like you might like FBP :slight_smile: Its a brilliant development system, but sadly, most developers struggle with the concepts and thus it has had limited traction over the last 50+yrs :slight_smile:

1 Like

Thanks for the reference, didn’t know about FBP.

I’ve been doing some tests also with ECS and Defold, but I’m still trying to figure out how to organize the code. I’m doing a mix of OOP and ECS and I’m quite happy with it!

2 Likes

Wrote a 3D engine in FBP back in early 2000’s. Was pretty awesome, could switch between DX7 DX8 and OGL live. When you have these sorts of architecture, you can do some pretty amazing things. :slight_smile:
If you get bored and want to look at something weird (and prob broken):
https://github.com/dlannan/deity
Uses old original Lua too :slight_smile: One day I will make a Defold version of it… when I retire from my day job :slight_smile:

3 Likes

Wow that’s quite a change to do it live!
I love this type of design that can allow you to abstract the game from the game engine itself, but at the same time I think: making a game is hard enough without trying to change the coding mindset lol

1 Like

Yeah. Its quite a different architecture. The key benefits are large tho:

  • runtime flexibility
  • no dependencies
  • cross platform is included (since you are using ports/channels to move streams)
  • very much like pc nodes in the web in design
  • extreme flexibility
  • huge reuse - this is probably one of the biggest benefits. Modules actually can be reused in any program, since the program becomes more of a “wiring” + data process.

The major downside is its complex to debug (hence why I builtin a debugger into the kernel).
Will revist with Defold one day. Its very powerful.

2 Likes

@Insality What are the underlying differences between system, system_command and system_event? Why and from what such differentiation comes from?

I have a note in Readme:

  • Systems are divided into “system”, “system_command”, and “system_event”:
    • System: Filters entities by required components and processes them. It usually returns up to three sub-systems: system, system_command, and system_event, and contains all system logic.
    • System Command: Describes an external API for the system with the “system_command” component and a list of “event” components to be triggered by the system. To spawn a command for the system, create an entity with the “system_command” component. This entity will be processed and removed by the system in the next frame.
    • System Event: Describes the event fields generated by the system. To spawn an event for the system, create an entity with the “system_event” component. This entity will be processed and removed by the system in the next frame.

Since in this example is mostly used a “event” (“command” and “event” entities) based approach instead of “update” (checking dirty or other properties to make an actions)

So short, system - logic, command - system api and bindings, event - that system produces to outside

2 Likes

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

9 Likes