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
- Latest stable version
- Repository
- ECSUtil - Utility Systems and Components
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:
- Entity Component Systems FAQ
- Entity Systems Wiki
- Evolve Your Hierarchy
- ECS on Wikipedia
- Entity Component Systems in Elixir
- 2017 GDC - Overwatch Gameplay Architecture and Netcode
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
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 theprocessIn
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
-
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)
-
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
-
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
-
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)
- After cleaning the environment, Roblox-ECS invokes the
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 currentframe
(processIn); thedelta
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
orrequireAny
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
- Fork it (https://github.com/nidorx/roblox-ecs/fork)
- Commit your changes (
git commit -am 'Add some fooBar'
) - Push to your master branch (
git push
) - Create a new Pull Request
License
This code is distributed under the terms and conditions of the MIT license.