ECS-Lua - A tiny and easy to use ECS (Entity Component System) engine

Friendship ended with OOP, now ECS is my best friend :+1:

Yeah, I’m really enjoying this system of organizing my code and the fact you can just add and drop components compared to inheritance issues of OOP especially with vague and broad classes like my current project which is to make multiple types of vehicles.

My problem with OOP

Yeah with OOP you will have to worry a lot about how you are going to handle inheritance. I was thinking of one technique I saw on the scripting support forms of separating it into ObjectShared, ObjectClient, and ObjectServer but then yeah eventually it’s just not versatile enough. Like what if I want a CarClient and PlaneClient class both inherited from general VehicleClient to share the same methods for example? Yeah thinking about how to avoid this inheritance issue is a pain so I switched to ECS which yeah solves the inheritance issue entirely since it’s all components that can be split apart and recombined.

Yeah that’s my big upside from using this resource so far the organizational benefits.

However, I’ll list down some issues I found while testing the system.

  1. Outdated documentation.

Yeah for anyone planning to use this I recommend going to GitHub as the forum documentation is outdated with outdated syntax like:

When it has been simplified to just:

   ClientSystems.testSystem = ECS.RegisterSystem()

Perhaps remove the tutorial entirely from the forum and just point towards the GitHub?

  1. The different work flow

Yeah this system requires a totally different work flow which takes some adjusting to especially the error messages. Here is one major one I found when I was experimenting:

Error message
--pointed to this part of the code:
      if table.getn(config.RequireAny) == 0 then
         error('You must enter at least one component id in the "RequireAny" field')
      end
--error message:
 ReplicatedStorage.Aero.Shared.ECS:129: You must enter at least one component id in the "RequireAny" field  -  Client  -  ECS:129
  23:53:42.893  Stack Begin  -  Studio
  23:53:42.893  Script 'ReplicatedStorage.Aero.Shared.ECS', Line 129 - function Filter  -  Studio  -  ECS:129
  23:53:42.893  Script 'ReplicatedStorage.Aero.Shared.ECS', Line 1879  -  Studio  -  ECS:1879
  23:53:42.893  Script 'ReplicatedStorage.Aero.Shared.ECS', Line 2528 - function CleanupEnvironment  -  Studio  -  ECS:2528
  23:53:42.893  Script 'ReplicatedStorage.Aero.Shared.ECS', Line 2623 - function Update  -  Studio  -  ECS:2623
  23:53:42.893  Script 'ReplicatedStorage.Aero.Shared.ECS', Line 2726  -  Studio  -  ECS:2726
  23:53:42.894  Stack End  -  Studio

Can you guess what I did wrong from that error message if you were new to using the system?

The answer is I created an entity without any components.

    local vehicleEntity = world.Create()

Yeah, I spent a frustrating amount of time wondering why this line caused an error, as I couldn’t understand at all the relationship between a component ID and why it caused an error during the update step of the world.

I guess the lesson is follow the tutorial? Anyways, please add this as an additional error message into the engine and a disclaimer in the documentation. Yeah I missed that “and” that was noted in the documentation :sob:
documentation quote:

It consists only of a unique ID and the list of components that make up this entity

The rest is mostly just syntax errors because of all the components you will need to access and require, for this, I recommend storing them all in one big table like how ECSUtil does it instead of the suggested method in the tutorial of separating each component into individual module scripts as component data is mostly just a bunch of one-liners.

And that’s it, otherwise, the documentation on GitHub was definitely well made and neat for how the API worked. For a potential improvement, I would swap out weapons for the System improvements:

   OnRemove = function(time, world, entity, index, weapons)
      -- on new entity
      print('Entity removed ', entity)
      return false
   end,

as weapons should have represented the component_N_data instead, so I guess it should have been left as a variadic function to represent … the general case of what the additional parameters are returning.

Also the outdated API here for system config caused an error once

   --  [Optional] Invoked before executing the update method
   BeforeUpdate = function(time, interpolation, world, system)
      -- called before update
      print(system.config.customConfig)
   end,

Yeah overall, a really great resource that works. Just could be a little more user friendly with the documentation :+1:

Here’s a really minor pull request to fix the error readability for newer users who don’t like to read documentation :stuck_out_tongue:

Am I doing it correctly?

4 Likes

I already said this is really awesome, and now that I am creating a game I am thinking to actually implement this into the game.

Sorry to ask, but I can‘t understand this. What is the problem with the inheritance? If you want that a CarClient and PlaneClient class share both inherited methods from a VehicleClient class, you just can make both CarClient and PlaneClient inherit from VehicleClient. This works, I self do it, so I can‘t understand what‘s the problem.

1 Like

It’s fine to ask, I should have explained clearer, but for your scenario it’s fine if you leave it like that as PlaneClient and CarClient.

…But what if you want to combine CarClient and PlaneClient because well it sounds cool right definitely a cool gameplay element should be easy with OOP right?

nope

I learned it from this article I found in this post after researching how to structure an OOP game. It’s a very famous problem since 1991 if we were to cite Wikipedia :stuck_out_tongue:

Even on wikipedia it lists no solutions to the problem only mitigations so yeah I wanted to avoid this problem entirely in order to make my code clean to edit in the future so yeah I was thinking of splitting it up into components…yep ECS should provide more flexibility in this area I’ll see how this goes tho.

2 Likes

Oh, I understand it better now. In java for example, one way to solve this would be to use interfaces. It‘s not the best, but it‘s better as nothing. I think you can inherit of multiple classes in C# (I think), but in Lua… there‘s no way, we don‘t have something like interfaces so this is unsolvable. Thanks for clarifying what you mean…

1 Like

I have been using the system and found an issue concerning detecting when a component is removed from an entity. Currently, the OnRemove event only fires when the entity is removed via:

world.Remove(gunEntity)--OnRemove Fires
world.Remove(gunEntity,replicationComponent)--OnRemove doesn't fire

So this will potentially cause a memory leaks if you have made an event during the OnEnter event. (For my scenario it’s when I have a weapon entity, with a fast cast component and I want to add and remove a replication on hit component everytime the ray hits an enemy).

To solve this I just stuffed a signal into the OnRemove function and called it ComponentRemoved and it fires every time the world.Remove(entity,component) function is called. Not sure if this is the best solution, but here is my fork of it, +another copy of ECS with the init.lua rojo format for putting modules as children of the script:

local signal
signal = world.ComponentRemoved:Connect(function(entityRemoved,component) 
   if entity == entityRemoved and component == someRandomComponent then
      entityEventThatIsUnconnected:Disconnect() -- something like this
signal:Disconnect()
   end
end)
1 Like

The problem was solved in the v1.2.2, with the addition of the OnExit method. The OnRemove method remains invoked when an entity is removed from the world

This new method works in conjunction with OnEnter , allowing the system to be informed when the entity has undergone changes that no longer satisfy the system filter.

In the example below, I have a TAG component called ControlsDisabled , whenever my local entity receives such a component ( world.Set(entity, ControlsDisabled) ), the system below blocks the player’s control.

After removing this component from the entity ( world.Remove(entity, ControlsDisabled) ) the system re-enables local player control

local ControlsDisabled = ECS.RegisterComponent('ControlsDisabled', nil, true) -- tag component

local Controls

return ECS.RegisterSystem({
   Name  = 'ControlsDisabled',
   Step  = 'process',
   Order = 10,
   RequireAll = {
      Player,
      LocalPlayer,
      ControlsDisabled
   },
   OnEnter = function(time, world, entity, index, players, localPlayers, controls)
      local player = players[index]
      
      Controls = require(player.Player.PlayerScripts:WaitForChild("PlayerModule")):GetControls()
      
      Controls:Disable() 

      return false
   end,
   OnExit = function(time, world, entity, index, players, localPlayers, controls)

      Controls:Enable() 

      return false
   end
})
1 Like

Thanks for this! I’m currently rewriting a project using Roblox-ECS and it’s going really nicely so far!

2 Likes

Any plans to introduce singleton components in the future?

Y’ello, inspired by @Dog2puppy recent Code templates plugin I have learnt how to make VsCode snippets because if you are going to use this engine/framework you will need em.

253 lines of components +100 client sided ones so far

VsCode Json Snippets
{
	// RobloxECS Engine component creation snippets
	"Create constructor component": {
		"scope": "Luau,lua,Lua",
		"prefix":  [".ConstructorComponent"],
		"body": [
			".${1:ComponentName}Component = ECS.RegisterComponent('${1:ComponentName}Component', function(${2:VariableName})",
			"\treturn ${2:VariableName}",
			"end)",
		],
		"description": "RobloxECS create a constructor component and index it in a table"
	},
	"Create local constructor component": {
		"scope": "Luau,lua,Lua",
		"prefix":  ["ConstructorComponent"],
		"body": [
			"${1:ComponentName}Component = ECS.RegisterComponent('${1:ComponentName}Component', function(${2:VariableName})",
			"\treturn ${2:VariableName}",
			"end)",
		],
		"description": "RobloxECS create a constructor component and put in a local variable"
	},
	"Create tag component": {
		"scope": "Luau,lua,Lua",
		"prefix": ".TagComponent",
		"body": [
			".${1:ComponentName}Component = ECS.RegisterComponent('${1:ComponentName}Component', nil, true)",
		],
		"description": "RobloxECS create a tag component"
	},
	"Create local tag component": {
		"scope": "Luau,lua,Lua",
		"prefix": "TagComponent",
		"body": [
			"${1:ComponentName}Component = ECS.RegisterComponent('${1:ComponentName}Component', nil, true)",
		],
		"description": "RobloxECS create a tag component and put in a local variable"
	},
}

Otherwise yeah really enjoying it, the versatility is like playing with blocks/legos but you have to manufacture each unique lego blocks by hand though it should be easier if you use the snippet now.

So far I have made a part damage system where each part is a armor entity with a root part and health using this engine really cool :+1:

2 Likes
--- Instances ---

local folder = workspace.Enemies

--- Services ---

local ECS = require(game.ReplicatedStorage:WaitForChild("ECS"))
local ECSUtil = require(game.ReplicatedStorage:WaitForChild("ECSUtil"))

--local ChasingComponent = require(script.ChasingComponent)

local StateComponent = require(script.StateComponent)

local ScannerSystem = ECS.RegisterSystem({
	Name = 'Scanner',
	--Step = 'transform',
	RequireAny = {
		ECSUtil.PositionComponent,
		StateComponent,
	},

	onEnter = function(time, world, entity, index, weapons)

		print('New entity added ', entity)
		return false
	end,

	update = function (time, world, dirty, entity, index, positions, State)

		print(entity)
		return false
	end
})

local world = ECS.CreateWorld(nil, { Frequency = 10 })
world.AddSystem(ScannerSystem)
ECSUtil.AddDefaultSystems(world)

local enemies = folder:GetChildren()

for e = 1, #enemies do

	for _, part in ipairs(enemies[e]:GetDescendants()) do

		if part:IsA("BasePart") then
			
			part:SetNetworkOwner(nil)
			if part.Name == "HumanoidRootPart" then
				
				local enemy = ECSUtil.NewBasePartEntity(world, part, true, false)
				world.Set(enemy, StateComponent)
			end
		end
	end
end

I can’t understand why it doesn’t work, I’ve tried everything but the update function is not being called. I also checked if the enemies were being set

Classic syntax error, I also received the same issue.

The syntax on the dev forum post is outdated gotta capitalize Update now and the rest of the system keys like OnEnter, Update, OnExit.

Otherwise the rest of the code looks ok with the components being properly set.

BIG UPDATE 2.0.0

New architecture

IMPORTANT! Various improvements and complete breaking of compatibility with previous versions

Documentation available at: ECS-lua - Entity Component System in Lua

Get involved
All kinds of contributions are welcome!

2 Likes

Update v2.1.0

Complete API documentation ECS-lua - Entity Component System in Lua

3 Likes

Wow amazing, loving the new qualifiers, I had the problem of being able to distinguish damage types like player 1 damage, player 2 damage, vs player 3 damage.

Also the new documentation and API is awesome and neater. I’ll have to refactor my code but it’ll be worth it as it was getting messy anyways👍.

2 Likes

The old architecture was very stuck, I applied some things that complicated the maintenance, the code didn’t smell good :sweat_smile:.

I confess that I liked the change I made, the code was clean and easy to maintain. I’m also migrating some code here to the new API :grimacing:.

2 Likes

Update v2.1.1

3 Likes

Update 2.2.0

  • Improvement in Timer.lua , removal of unnecessary properties and simplification of the loop
  • Added Entity:GetAny(CompType) method to get any qualifier of a type
  • Fixed Entity:Set() , validating type(value) == "table" before accessing component properties
  • Creating the Pong Game Example

Pong game

Created this game to be used in documentation tutorials.

The game’s source code is available at ecs-lua/examples/pong at master · nidorx/ecs-lua · GitHub

The tutorial is not ready yet.

Summary

4 Likes

I have used the new system and it works great like the old one but now the code is more understandable :+1: especially with understanding how the loop manager ties with RunService.

However, I would like to know if there is a better way of accessing an entities world? Currently I have to create a WorldComponent for an entity to access it which works but feels weird in a way, let me know if it’s ok.

My use case is for something like an entity factory for translating collection service instances to ECS entities or an gun entity creating a bullet entity.

I think this approach works well and is similar to Knit Component which has worked well in the past and can be applied to the ECS engine. I will also share some things I found while working with this resource.

Example entity factory module
--Entity factory module
local CollectionService = game:GetService("CollectionService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local ECS = _G.ECS

local Components = require(script.Parent.Parent.Components)
local CollectionServiceTagComponent = Components.CollectionServiceTagComponent
local WorldComponent = Components.WorldComponent

local World, System, Query, Component = ECS.World, ECS.System, ECS.Query,
                                        ECS.Component

local CollectionClassSystem = System("transform", 10, Query.All(
                                         CollectionServiceTagComponent,
                                         WorldComponent))

local function createAndSetupEntity(world, setupEntityFunction, instance)
    local entity = world:Entity()
    setupEntityFunction(entity, instance)

    return entity
end

local function setupCollectionClassInstance(tag, setupEntityFunction, world)

    local entities = {}
    CollectionService:GetInstanceAddedSignal(tag):Connect(function(instance)
        entities[instance] = createAndSetupEntity(world, setupEntityFunction, instance)
    end)

    local instances = CollectionService:GetTagged(tag)

    for _, instance in pairs(instances) do
        entities[instance] = createAndSetupEntity(world, setupEntityFunction, instance)
    end

    CollectionService:GetInstanceRemovedSignal(tag):Connect(function(instance)
        local entity = entities[instance]
        world:Remove(entity)
    end)
end

function CollectionClassSystem:OnEnter(Time, entity)
    local csData = entity[CollectionServiceTagComponent]
    local world = entity[WorldComponent].value
    local tag = csData.Tag
    local components = csData.Components
    setupCollectionClassInstance(tag, components, world)
    return true
end

return CollectionClassSystem

Within the main script

Usage within the primary server script
local vehicleEntityFactory = world:Entity()
local function setupVehicleEntity(vehicleEntity, modelInstance)
    vehicleEntity:Set(ModelComponent(modelInstance))
    vehicleEntity:Set(VehicleSeatComponent())
    vehicleEntity:Set(WorldComponent(world))
    -- vehicleEntity:Set(HealthComponent())
end
vehicleEntityFactory:Set(CollectionServiceTagComponent({"Vehicle", setupVehicleEntity}))
vehicleEntityFactory:Set(WorldComponent(world))

Also some more snippets I made

Summary
{
    "Class": {
        "prefix": ["class"],
        "body": [
            "local ${0:$TM_FILENAME_BASE} = {}",
            "${0:$TM_FILENAME_BASE}.__index = ${0:$TM_FILENAME_BASE}",
            "",
            "",
            "function ${0:$TM_FILENAME_BASE}.new()",
            "\tlocal self = setmetatable({}, ${0:$TM_FILENAME_BASE})",
            "\treturn self",
            "end",
            "",
            "",
            "function ${0:$TM_FILENAME_BASE}:Destroy()",
            "\t",
            "end",
            "",
            "",
            "return ${0:$TM_FILENAME_BASE}",
            ""
        ],
        "description": "Lua Class"
    },

    "ECS System": {
        "prefix": "ECSSystem",
        "body": [
          "local ReplicatedStorage = game:GetService(\"ReplicatedStorage\")",
          "",
          "local Components = require(script.Parent.Parent.Components)",
          "",
          "local ECS = require(ReplicatedStorage.Shared.ECSFolder.ECS)",
          "",
          "local World, System, Query, Component = ECS.World, ECS.System, ECS.Query, ECS.Component",
          "",
          "local ${TM_FILENAME_BASE} = System(\"transform\", 1, Query.All())",
          "",
          "",
          "",
          "return ${TM_FILENAME_BASE}"
        ],
        "description": "ECS System"
      },

    "ECS OnArchetype": {
      "prefix": "ECSOnMethod",
      "body": [
        "function ${TM_FILENAME_BASE}:${1|OnEnter,OnExit,OnRemove|}(Time, entity)",
        "",
        "end"
      ],
      "description": "ECS OnEnter"
    },

    "ECS Update": {
      "prefix": "ECSUpdate",
      "body": [
        "function ${TM_FILENAME_BASE}:Update(Time)",
        "    for i, entity in self:Result():Iterator() do",
        "    end",
        "end"
      ],
      "description": "ECS Update"
    },
    
    "Component Template": {
      "prefix": "ECSComponent",
      "body": [
        "local ${TM_FILENAME_BASE} = _G.ECS.Component()",
        "",
        "return ${TM_FILENAME_BASE}"
      ],
      "description": "Component Template"
    },
}

Even though I put the components in _G for “easy access” similar to your example most of the time I use a module to just fetch and run the components module from a folder like so which works great.

Also some meta table thing to catch that weird error when I haven’t created a component yet.

Component getter
local ReplicatedStorage = game:GetService("ReplicatedStorage")
--[[
    Collects all the descendant components and stores it,
    also puts it in _G cuz why not
]]

local Components = {}

local descendants = script:GetDescendants()

for _, componentModule in pairs(descendants) do
    if componentModule:IsA("ModuleScript") then
        local component = require(componentModule)
        Components[componentModule.Name] = component
    end 
end

--Also add in shared components, between server and client
local SharedComponents = require(ReplicatedStorage.Shared.Components)
for key, value in pairs(SharedComponents) do
    Components[key] = value
end

local mt = {}
mt.__index = function(table, keyComponentName)
    warn(keyComponentName, ", hasn't been created yet!")
end

setmetatable(Components,mt)

return Components

Also I have found out that you don’t need Knit when using ECS, you can replicate the functionalities of Knit yourself and it saves the time of having to look at Knit documentation as well you understand it better if you do it yourself.

Also saves a lot of looking through folders:

Before:
image
After:
image

Yeah it was pretty messy trying to combine both systems, an additional 2 folders full of stuff to look at. Now it’s more like the Pong example.

can you make it uncopylocked so we can see how your module applies to a game in roblox?

Yeah! This was where I was at too, I had to do the same thing last night… only problem was ECS.Component kept returning an empty table… I checked the source code and I figured out the initializer wasnt being called and having its data added to the class, ill make an issue on the repo later today but heres what I did incase anybody encounters a similar issue

Components and Systems are folders containing modules that return respective components and systems for easy expandability and organization etc:

https://gyazo.com/8505edb997ce25dc264455dbdcc34693


https://gyazo.com/f4188039c9a6b95db58a605355a0d033

Heres my fix for the Component constructor:


https://gyazo.com/b6f765256d00493c90ec89deebe16470

Basically, create a reference to the data you pass your component and have the __index metamethod check that table and return the value.

No clue if anyone else had this issue but its a quickfix

2 Likes