This is an experiment in introducing an entity component system into Roblox. In our model:
- The "entity" is Roblox Instances.
- The "component" is Lua objects.
- The "system" is not formalized.
This library allows you to tie Lua objects to Instances in an intuitive fashion. Instance tagging is used to figure out which instances should have which objects.
One of the key concepts with this system is that components can get handles to other components and call methods on them. This allows for a great deal of abstraction and separation of concerns without having to resort to awkward constructs like BindableEvents.
The source for the library is in the lib
directory. Everything you
need is there. The library has no dependencies. You can add it to your
project using Rojo like so:
"EntitySystem": {
"path": "EntitySystem/lib/", // Assumes you're using it as a submodule, but anything works.
"target": "ReplicatedStorage.EntitySystem" // Wherever you like.
},
We'll start by creating a component type. We'll use a death brick for this example.
local EntitySystem = require(script.Parent.EntitySystem)
local DeathBrick = EntitySystem.Component:extend("DeathBrick")
function DeathBrick:added()
self.maid.touchConn = self.instance.Touched:Connect(function(part)
part:BreakJoints()
end)
end
return DeathBrick
On its own, this snippet won't do anything. Create a folder for all your
components (you'll see why you want to do this in a moment), and add
this as a ModuleScript
(this part's important as well).
In order to make the components work, we have to create a Core
and
register the components with it. The components we define with snippets
such as the one above are simple data holders, they don't do anything on
their own.
You'll want to create a Core
on both the client and server, most
likely.
local EntitySystem = require(script.Parent.EntitySystem)
-- The array we pass here is a list of arguments. This is where we'll
-- pass plugins and other data.
local core = EntitySystem.Core.new({})
-- The folder from earlier.
local componentsFolder = script.Parent.Components
-- We put them all together so that we don't have to manually list them here.
for _,module in pairs(componentsFolder:GetChildren()) do
-- registerComponent() takes a component description, which is what we created earlier in that snippet.
core:registerComponent(require(module))
end
Why don't we define components in standalone scripts or localscripts? The reason for this is that components can actually look up references to each other using their descriptor. For example:
local EntitySystem = require(script.Parent.EntitySystem)
local DeathBrick = require(script.Parent.DeathBrick)
local MyComponent = EntitySystem.Component:extend("MyComponent")
function MyComponent:added()
-- If MyComponent and DeathBrick are both attached to the same object, this will return a reference to it.
local deathBrick = self:getComponent(DeathBrick)
-- We didn't actually define any methods or properties on DeathBrick so let's make some up.
deathBrick.someValue = 4
deathBrick:callSomeMethod()
end
return MyComponent
Constructs a new core using the given parameters. args
is formattted like so:
{
plugins = {
-- Array of plugins, e.g. EntitySystem.SteppedPlugin.
},
-- NetworkPlugin arguments
isServer: bool,
remoteEvent: RemoteEvent,
}
Registers a description of a component and begins instantiating it for all of the instances that need it. It will throw if you try to register two components with the same className
.
Returns the Lua object associated with the given component/instance pair.
The same as above, but it will create the object if it doesn't already exist.
Creates a new component description. The className
is used to know which CollectionService tag this component corresponds to.
A reference to the instance this component is attached to.
A Maid pattern is embedded into every component. The basic idea is that you can hand things to the maid and it will automatically clean them up.
Types of objects it can clean up:
Instance
: CallsDestroy()
.RBXScriptConnection
: CallsDisconnect()
.table
: Tries to calldestroy()
.function
: Calls it.
To provide things to the maid, you can either set it as a key, or you can use the :give()
method.
Setting the same key twice will clean up the previous thing that was in the slot.
Anything may be used as a key as long as it wouldn't shadow a method on the Maid class itself.
The class name of the component, as passed to extend()
.
Called when the component is instantiated. Note that components can be in a state where they've been created but have not have added()
called yet, when calling getComponent
from added()
(because of ordering).
Called when the component is removed from the instance.
Locate a component attached to the same instance.
Locate a component attached to the instance's parent.
Locate all of the components of that type attached to any of the instance's descendants.
This repository includes a plugin for manipulating components. It shows a properties panel where the properties can be edited.
There's four classes that are relevant to the library's design.
Core
: This is where all of the real state manipulation happens.ComponentDesc
: This is how we define/describe components at a high level. These are created usingEntitySystem.Component:extend()
Component
: This is the instantiation of a component attached to an instance.ComponentManager
: This handles CollectionService tags and creates/managesComponent
s. They are created when you callregisterComponent
.
The reason why we don't simply have ComponentDesc
alone and have it do all the IO is for a couple of key reasons:
- It keeps the code cleaner and easier to understand.
- It makes it so that we don't have to do hacks to make Play Solo work.
To enable these, pass them into the plugins
array argument to Core.new()
, like so:
local core = EntitySystem.Core.new({
plugins = {
EntitySystem.NetworkPlugin,
EntitySystem.SteppedPlugin,
}
})
This plugin implements client-server communication between remote analogues of the same component.
In order for it to work, you must pass a RemoteEvent and inform it whether it's acting as a client or a server. We can't just use RunService:IsServer()
because of play solo.
All of the functions in the API communicate with the remote end of the network connection, with a component of the same name attached to the same object. Note, only the className
has to be the same. They do not have to be the same component.
Server only. Sends a message to a specific player's client.
Server only. Sends a message to all clients.
Server only. Invoked when receiving a message corresponding to a sendServer()
.
Client only. Sends a message to the server.
Client only. Invoked when receiving a message corresponding to a sendClient()
/sendBroadcast()
.
This plugin allows you to easily run code during RenderStep or Heartbeat without having to manually manage RunService connections.
Invoked every Heartbeat if it is defined.
Invoked every RenderStep if it is defined.