Roblox-ECS - A tiny and easy to use ECS (Entity Component System) engine [with fixed timestep and interpolation]

Roblox-ECS is a tiny and easy to use ECS (Entity Component System) engine for game development on the Roblox platform

TLDR; There is a very cool tutorial that shows you in practice how to create a small shooting system (The result of this tutorial can be seen at https://github.com/nidorx/roblox-ecs/blob/master/docs/tutorial.rbxl).

IMPORTANT! This engine is still in development, therefore subject to bugs

Links

Installation

You can do the installation directly from Roblox Studio, through the Toolbox search for Roblox-ECS, this is the minified version of the engine (https://www.roblox.com/library/5887881675/Roblox-ECS).

If you want to work with the original source code (for debugging or working on improvements), access the repository at https://github.com/nidorx/roblox-ecs

Entity-Component-System

Entity-Component-System (ECS) is a distributed and compositional architectural design pattern that is mostly used in game development. It enables flexible decoupling of domain-specific behavior, which overcomes many of the drawbacks of traditional object-oriented inheritance

For further details:

Roblox Pipeline

Before going into the details, let’s review some important concepts about how the Roblox game engine works

Most likely you have seen the illustration below, made by zeuxcg and enriched by Fractality_alt. It describes the Roblox rendering pipeline. Let’s redraw it so that it is clearer what happens in each frame of a game in Roblox

image

In the new image, we have a clear separation (gap between CPU1 and CPU2) of the Roblox rendering process, which occurs in parallel with the simulation and processing (game logic) of the next screen

The green arrows indicate the start of processing of the new frame and the return of execution after the completion of the two processes that are being executed in parallel (rendering of the previous screen and processing of the current frame)

The complete information on the order of execution can be seen at https://developer.roblox.com/en-us/articles/task-scheduler

note the distance between the initialization of the two processes in the image is just to facilitate understanding, in Roblox both threads are started at the same time

Based on this model, Roblox-ECS organizes the execution of the systems in the following events. We call them steps

Roblox-ECS steps

Roblox-ECS allows you to configure your systems to perform in the steps defined below.

In addition to defining the execution step, you can also define the execution order within that step. By default, the order of execution of a system is 50. When two or more systems have the same order of execution, they will be executed following the order of insertion in the world

  • processIn - Executed once per frame

    This is the first step to be executed in a frame. Use this step to run systems that translate the user’s input or the current state of the workspace to entity components, which can be processed by specialized systems in the next steps

    Eg. Use the UserInputService to register the player’s inputs in the current frame in a pool of inputs, and, in the processIn step, translate these commands to the player’s components. Realize that the same logic can be used to receive entries from the server and update local entities that represent other players

    -- InputHandlerUtils.lua
    local UserInputService = game:GetService("UserInputService")
    
    local pool = { FIRE = false }
    
    UserInputService.InputBegan:Connect(function(input, gameProcessed)
       if input.UserInputType == Enum.UserInputType.MouseButton1 then
          pool.FIRE = true
       end
    end)
    
    return pool
    
    --------------------------------
    
    -- InputMapSystem.lua
    local ECS = require(game.ReplicatedStorage:WaitForChild("ECS"))
    local FiringComponent = require(game.ReplicatedStorage:WaitForChild("FiringComponent"))
    
    local pool = require(game.ReplicatedStorage:WaitForChild("InputHandlerUtils"))
    
    return ECS.System.register({
       name = 'InputMap',
       step = 'processIn',
       order = 5,
       requireAll = {
          PlayerComponent
       },
       update = function (time, world, dirty, entity, index, players)
          local changed = false
    
          if pool.FIRE then
             world.set(entity, FiringComponent, { FiredAt = time.frame })
             changed = true
          end
    
          pool.clear()
    
          return changed
       end
    })
    
  • process - Executed 0 or more times per frame

    This step allows the execution of systems for game logic independent of Frame-Rate, obtaining determinism in the simulation of the rules of the game

    Independent Frame-Rate games are games that run at the same speed, no matter the frame rate. For example, a game can run at 30 FPS (frames per second) on a slow computer and 60 FPS on a fast one. A game independent of the frame rate progresses at the same speed on both computers (the objects appear to move at the same speed). On the other hand, a frame rate-dependent game advances at half the speed of the slow computer, in a kind of slow motion effect

    Making frame rate independent games is important to ensure that your game is enjoyable and playable for everyone, no matter what type of computer they have. Games that slow down when the frame rate drops can seriously affect gameplay, making players frustrated and giving up! In addition, some systems have screens with different refresh rates, such as 120 Hz, so independence of the frame rate is important to ensure that the game does not accelerate and is impossibly fast on these devices

    This step can also be used to perform some physical simulations that are not met (or should not be performed) by the Roblox internal physics engine.

    The standard frequency for executing this step in a world is 30Hz, which can be configured when creating a world

    In the tutorial topic there is a demonstration of the use of interpolation for smooth rendering display even when updating the simulation in just 10Hz

    Read more

  • processOut - Executed once per frame

    Use this step when your systems make changes to the components and these changes imply the behavior of the Roblox internal physics simulations, therefore, the workspace needs to receive the update for the correct physics engine simulation

  • transform - Executed once per frame

    Use this step for systems that react to changes made by the Roblox physics engine or to perform transformations on game objects based on entity components (ECS to Workspace)

    Ex. In a soccer game, after running the physics engine, check if the ball touched the net, scoring a point

    Ex2. In a game that is not based on the Roblox physics engine, perform the interpolation of objects based on the positions calculated by the specialized systems that were executed in the PROCESS step

  • render - Executed once per frame

    Use this step to run systems that perform updates on things related to the camera and user interface.

    IMPORTANT! Only run light systems here, as the screen design and the processing of the next frame will only happen after the completion of this step. If it is necessary to make transformations on world objects (interpolations, complex calculations), use the TRANSFORM step

Cleaning phase

At the end of each step, as long as there is dirt, Roblox-ECS sanitizes the environment.

In order to increase performance and maintain the determinism of the simulation, changes that modify the organization of the environment (change in chunks) are applied only in this phase.

At this stage, the following procedures are performed, in that order

  1. Removing entities
    • If during the execution of the step your system requests the removal of an entity from the world, Roblox-ECS clears the data of that entity in memory but does not immediately remove the entity from Chunk, it only marks the entity for removal, which happens at the moment current (cleaning phase)
  2. Changing the entity’s archetype
    • Entities are grouped in chunk based on their archetype (types of existing components). When you add or remove components from an entity you are modifying its archetype, which should modify the chunk of that entity. When this happens, Roblox-ECS starts to work internally with a copy of that entity, without removing it from the original chunk. This chunk change only occurs during this cleaning phase
  3. Creation of new entities
    • When a new entity is added to the world by its systems, Roblox-ECS houses that entity in specific chunks of new entities, and only at that moment these entities are copied to their definitive chunk
  4. Invocation of the systems “onEnter” method
    • After cleaning the environment, Roblox-ECS invokes the onEnter method for each entity that has been added (or that has undergone component changes and now matches the signature expected by some system)

Roblox-ECS

We will now know how to create Worlds, Components, Entities and Systems in Roblox-ECS

World

The World is a container for Entities, Components, and Systems.

To create a new world, use the Roblox-ECS newWorld method.

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

local world = ECS.newWorld(

   -- [Optional] systems
   {SystemA, SystemB}, 

   -- [Optional] config
   { 
      frequency = 30, 
      disableDefaultSystems = false, 
      disableAutoUpdate = false
   }
)

Component

Represents the different facets of an entity, such as position, velocity, geometry, physics, and hit points for example. Components store only raw data for one aspect of the object, and how it interacts with the world

In other words, the component labels the entity as having this particular aspect

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

return ECS.Component.register(
   -- name
   'Box',

   -- [Optional] constructor
   function( width, height, depth)
      if width == nil then width = 1 end

      return {width, height, depth}
   end,

   -- [Optional] is tag? Defaults false
   false
)

The register method generates a new component type, which is a unique identifier

  • constructor - you can pass a constructor to the component register. The constructor will be invoked whenever the component is added to an entity
  • Tag component - The tag component or “zero size component” is a special case where a component does not contain any data. (Eg: EnemyComponent can indicate that an entity is an enemy, with no data, just a marker)

Entity

The entity is a general purpose object. An entity is what you use to describe an object in your game. e.g. a player, a gun, etc. It consists only of a unique ID and the list of components that make up this entity

local cubeEntity = world.create()

Adding and removing components

At any point in the entity’s life cycle, you can add or remove components, using set and remove methods

local BoxComponent = require(path.to.BoxComponent)
local ColorComponent = require(path.to.ColorComponent)

-- add components to entity
world.set(cubeEntity, BoxComponent, 10, 10, 10)
world.set(cubeEntity, ColorComponent, Color3.new(1, 0, 0))


-- remove component
world.remove(cubeEntity, BoxComponent)

Accessing components data

To gain access to the components data of an entity, simply use the get method of the world

local color = world.get(cubeEntity, ColorComponent)

Check if it is

To find out if an entity has a specific component, use the has method of the world

if world.has(cubeEntity, ColorComponent) then
   -- your code
end

Remove an entity

To remove an entity, use the “remove” method from the world, this time without informing the component.

world.remove(cubeEntity)

IMPORTANT! The removal of the entity is only carried out at the end of the execution of the current step, when invoking the remove method, the engine cleans the data of that entity and marks it as removed. To check if an entity is marked for removal, use the alive method of the world.

if not world.alive(cubeEntity) then
   -- your code
end

System

Represents the logic that transforms component data of an entity from its current state to its next state. A system runs on entities that have a specific set of component types.

In Roblox-ECS, a system has a strong connection with component types. You must define which components this system works on in the System registry.

If the update method is implemented, it will be invoked respecting the order parameter within the configured step. Whenever an entity with the characteristics expected by this system is added on world, the system is informed via the onEnter method.

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

-- Components
local FiringComponent = require(path.to.FiringComponent)
local WeaponComponent = require(path.to.WeaponComponent)

return ECS.System.register({
   name = 'PlayerShooting',

   -- [Optional] defaults to transform
   step = 'processIn',

   -- [Optional] Order of execution within that step. defaults to 50
   order = 10,

   -- requireAll or requireAny
   requireAll = {
      WeaponComponent
   },

   --  [Optional] rejectAll or rejectAny
   rejectAny = {
      FiringComponent
   },

   --  [Optional] Invoked when an entity with these characteristics appears
   onEnter = function(time, world, entity, index, weapons)
      -- on new entity
      print('New entity added ', entity)
      return false
   end,

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

   -- [Optional] Invoked for each entity that has the characteristics 
   -- expected by this system
   update = function (time, world, dirty, entity, index, weapons)

      local isFiring = UserInputService:IsMouseButtonPressed(
         Enum.UserInputType.MouseButton1
      )

      if isFiring  then
         -- Add a firing component to all entities when mouse button is pressed
         world.set(entity, FiringComponent, { FiredAt = time.frame })
         return true
      end

      return false
   end
})

update

The update method has the following signature:

update = function (time, world, dirty, entity, index, [component_N_data...])

   local changed = false

   return changed
end
  • time : Object containing the time that the last execution of the process step occurred; the time at the beginning of the execution of the current frame (processIn); the delta time, in seconds passed between the previous and the current frame
    • { process = number, frame = number, delta = number }
  • world: Reference to the world in which the system is running
  • dirty : Informs that the chunk (see performance topic) currently being processed has entities that have been modified since the last execution of this system
  • entity : Entity ID being processed
  • index : Index, in the chunk being processed, that has the data of the current entity.
  • component_N_data : The component arrays that are processed by this system. The ordering of the parameters follows the order defined in the requireAll or requireAny attributes of the system.

As in this architecture you have direct access to the data of the components, it is necessary to inform on the return of the function if any changes were made to this data.

Performance TIP, dirty version

As with Unity ECS, Roblox-ECS systems are processed in batch.

Component data is saved in chunks, which allows queries by entities with the expected characteristics to be made more quickly.

In the update method, your system is able to know if this chunk being processed at the moment has entities that have changed, through the dirty parameter. Using this parameter you can skip the execution of your system when there has been no change since the last execution of your system for this specific chunk

See that this parameter says only if there are any entities modified in this chunk, but it does not say exactly which entity is

For more details, see the links The Chunk data structure in Unity and Designing an efficient system with version numbers

Adding to the world

To add a system to the world, simply use the addSystem method. You can optionally change the order of execution and pass any configuration parameters that are expected by your system

local PlayerShootingSystem = require(path.to.PlayerShootingSystem)

world.addSystem(PlayerShootingSystem, newOrder, { customConfig = 'Hello' })

@Todo

  • Remove systems
  • Destroy world
  • Improve technical documentation
  • Perform benchmark with a focus on Data-oriented design
  • From the benchmarks, make improvements to the engine
  • Prepare for multi-thread?
  • Debugging system (External tool? Plugin?)
  • Statistics (Per system, per component)
  • Template export?
  • Facilitate the creation of unit tests
  • Facilitate systems benchmark
  • Create unit tests for the engine (quality assurance)

Contributing

You can contribute in many ways to this project.

Translating and documenting

I’m not a native speaker of the English language, so you may have noticed a lot of grammar errors in this documentation.

You can FORK this project and suggest improvements to this document (https://github.com/nidorx/roblox-ecs/edit/master/README.md).

If you find it more convenient, report a issue with the details on GitHub issues.

Reporting Issues

If you have encountered a problem with this component please file a defect on GitHub issues.

Describe as much detail as possible to get the problem reproduced and eventually corrected.

Fixing defects and adding improvements

  1. Fork it (https://github.com/nidorx/roblox-ecs/fork)
  2. Commit your changes (git commit -am 'Add some fooBar')
  3. Push to your master branch (git push)
  4. Create a new Pull Request

License

This code is distributed under the terms and conditions of the MIT license.

51 Likes

I really like this, if you have tried minecraft modding then you will love this too. Its really what I think we are missing. I will use this for my game

3 Likes

This system is pretty awesome, I’ll def use it

2 Likes

Interesting content related to ECS and netcode

3 Likes

Original https://habr.com/ru/company/pixonic/blog/415959/

2 Likes

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?

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.

1 Like

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