Rocs
Rocs is a progressive entity-component-system (ECS) library developed for use in the Roblox game engine.
Rocs also performs the role of a general game state management library. Specifically, Rocs facilitates managing resources from multiple, unrelated places in your code base in a generic way.
To that end, Rocs allows more than one of the same component to exist at the same time on a single entity. After every mutation, Rocs uses functions that you define to determine the single source of truth for that component. This allows disparate locations in your code base to influence a shared state.
The Rocs workflow encourages compositional patterns, decoupled separation of concerns, and generic components which are reusable across many games. Because of this, patterns such as higher order components emerge naturally and allow you to keep grouped state changes atomic and concise.
Additionally, Rocs supports middleware by means of life cycle hooks and augmenting core functionality. By default, two optional middleware are included:
- Selectors, which offer a way to query against your current set of components within your game and create systems with behaviors which are only active when those queries are met.
- Replication, which offers a batteries-included way to replicate components (or parts of components) you choose to clients.
To get started with Rocs, sync in with Rojo or download the latest release.
Use cases
Register the component:
rocs:registerComponent({
name = "WalkSpeed";
reducer = function (values)
return math.min(unpack(values))
end;
check = t.number;
entityCheck = t.instance("Humanoid");
onUpdated = function (self)
self.instance.WalkSpeed = self:getOr(16)
end;
})
Inside your weapon code:
local entity = rocs:getEntity(humanoid, "weapon")
-- On Equipped:
entity:addComponent("WalkSpeed", 10)
-- On Unequipped:
entity:removeComponent("WalkSpeed")
Inside your menu code:
local entity = rocs:getEntity(humanoid, "menu")
-- On Opened:
entity:addComponent("WalkSpeed", 0)
-- On Closed:
entity:removeComponent("WalkSpeed")
Influencing shared state from disparate code locations
A classic example of the necessity to share resources is changing the player's walk speed.
Let's say you have a heavy weapon that you want to have slow down the player when he equips it. That's easy enough, you just set the walk speed when the weapon is equipped, and set it back to default when the weapon is unequipped.
But what if you want something else to change the player's walk speed as well, potentially at the same time? For example, let's say you want opening a menu to set
the character's WalkSpeed to 0
.
If we follow the same flow as when we implemented the logic for the heavy weapon above, we now have a problem: The player can equip the heavy weapon and then open and close the menu. Now, the player can walk around at full speed with a heavy weapon equipped when they should still be slowed!
Rocs solves this problem correctly by allowing you to apply a movement speed component from each location in the code base that needs to modify the walk speed. Each component can provide its own intensity level for which to affect the movement speed. Then, every time a component is added, modified, or removed, Rocs will group all components of the same type and determine a single value to set the WalkSpeed to, based on your defined reducer function.
In this case, the function will find the lowest value from all of the components, and then the player's WalkSpeed will be set to that number. Now, there is only one source of truth for the player's WalkSpeed, which solves all of our problems. When there are no longer any components of this type, you can clean up by setting the player's walk speed back to default in a destructor.
Concepts
This section will give an overview of the fundamental concepts used in Rocs so that you can understand the details in the following sections. Don't focus too much on the code right now, that will come later.
Rocs instance
Instantiating an instance of Rocs:
local rocs = Rocs.new()
const rocs = new Rocs()
Multiple versions of Rocs can exist at the same time in the same place and do not affect each other. Rocs instance refers to the instance of Rocs that you instantiated. Typically, you should only have one instance of Rocs per game. However, this allows Rocs to be used by libraries independently of the containing game.
Entities
Getting an entity wrapper for Workspace, with the scope
"my scope"
:
local entity = rocs:getEntity(workspace, "my scope")
const entity = rocs.getEntity(workspace, 'my scope')
Entities are the objects that we put components on. These can be Roblox Instances or objects that you create yourself. "Instance" will generally be used to refer to these objects for purposes of conciseness, but remember that it does not need to be an actual Roblox Instance.
For the sake of ergonomic API, Rocs provides an entity wrapper class which has many of the methods you will use to modify components. However, state is associated internally with the instance for which the wrapper belongs, and not within the wrapper itself. Multiple entity wrappers can exist for one instance.
Entity wrappers must be created with a scope, which is some value associated with the source of the state change. For example, if you wanted to stop a
player from moving when they open a menu, then the entity wrapper you create to enact that change could be created with the scope "menu"
(a string).
If instead some state change on a player was coming from within a tool, you could use the Tool
object as the scope.
The rationale behind this requirement is explained in the next section.
Components
Adding a component to an entity:
entity:addComponent("MyComponent", {
foo = "bar";
})
import { MyComponent } from 'path/to/component'
entity.addComponent(MyComponent, {
foo: 'bar'
})
Components are, in essence, named groups of related data which can be associated with an entity. Every type of component you want to use must be explicitly registered on the Rocs instance with a unique name along with various other options.
In Rocs, components are a little different from a typical ECS library. In order to allow disparate locations of your code to influence and mutate the same state in a safe way, multiple of the same component can exist on one entity.
As discussed in the previous section, entity wrappers must be created with a scope. Multiple of the same component on one entity are distinguished by the scopes that you choose when adding the component. When you add or remove a component from an entity, you only affect components which are also branded with your scope.
Reduced values
Every time you add, modify, or remove a component, all components of the same type are grouped and fed into a reducer function. The resultant value is now referred to as a reduced value.
Component Aggregates
When you register a component type with Rocs, the table you register becomes the metatable of that component's Aggregate. Aggregates are essentially class instances which are used to represent all components of the same type on a single entity. They provide methods and have properties where you can access data from this component externally.
Aggregates may have their own constructors and destructors, life cycle hooks, and custom methods if you wish.
Only one instance of an Aggregate will exist per entity per component type. So if you have an entity with two components of type "MyComponent"
,
there will only be one MyComponent
Aggregate for this entity.
Tags and Base Scope Components
Component types may also be optionally registered with a [CollectionService] tag. Rocs will then automatically add and remove this component from the Instance when the tag is added or removed.
For situations like this where there is a component that exists at a more fundamental level, the base scope is used. The base scope is just like any other component scope, except it has special significance in performing the role as the "bottom-most" component (or in other words, the component that holds data for a component type without any additional modifiers from other places in the code base).
Changing the
name
field of the base component.
entity:getBaseComponent("Character"):set("name", "New name")
The base scope is used for CollectionService tags, but is also useful in your own code. For example, if you had a component that represents a character, situations may
arise where you want to modify data destructively, such as in changing a character's name. We aren't influencing the existing name, but instead we are
completely changing it. In this case, you should change the "name"
field on the base component directly. (This assumes that you initialized the base
component earlier in your code, which should be the case if you have basic data like this).
Base components also have special precedence when passed to reducer functions: the base component is always the first value. Values from other scopes subsequently follow in the array in any order. This is useful for situations when you want non-base scopes to partially override fields from the base scope.
Patterns
Higher-Order Components
rocs:registerComponent({
name = "Empowered";
reducer = rocs.reducers.structure({
intensity = rocs.reducers.highest;
});
check = t.interface({
intensity = t.number;
});
onUpdated = function (self)
local entity = rocs:getEntity(self.instance, self) -- This component is the scope.
entity:addComponent("Health", self:getAnd("intensity", function(intensity)
return {
MaxHealthModifier = intensity;
}
end))
entity:addComponent("WalkSpeed", self:getAnd("intensity", function(intensity)
return intensity * 16
end))
end;
})
When creating games, it is often useful to have multiple levels of abstraction when dealing with state changes.
For example, you might have a WalkSpeed
component which is only focused on dealing with the player's movement speed and nothing more. You might also have
a Health
component which only deals with the player's health. It's a good idea to create small components like this with each of their concerns wholly
separated and decoupled.
However, it can become tiresome to modify these components individually if you often find yourself changing them in tandem. For example, if your game had a mechanic where players regularly received a buff that makes them walk faster and have more health, it's a good idea to group these state changes together so that they stay in sync and are applied atomically.
Higher-order components allow you to do just this. A higher-order component is simply just a component that creates other components within their life cycle methods. In
the code sample, we use the onUpdated
life cycle method to add two components to the instance that this component is already attached to.
getAnd
is a helper function on Aggregates which gets a field from the current component's data and then calls the callback only if that value is non-nil.
If the value is nil, getAnd
just returns nil
immediately. Adding a component with the value of nil
is the same as removing it.
Meta-components
entity:addComponent("ComponentName", data, {
Replicated = true
})
The above code is equivalent to:
local component = entity:addComponent("ComponentName", data)
local componentEntity = rocs:getEntity(component)
componentEntity:addComponent("Replicated", true)
Not only can components create other components, but components can actually be on other components. We refer to these components which are on other components as meta-components. Meta-components exist on the Aggregate instance of another component.
Meta-components are useful to store state about components themselves rather than whatever the component manages. For example, the optional built-in
Replication
component, when present on another component, will cause that parent component to automatically be replicated over the network to clients.
A short hand exists in the addComponent
method on entity wrappers to add meta-components quickly immediately after adding your main component. You can also
define implicit meta-components upon component registration which will always be added to those specific components via the components
field.
Components API
Registering a component
rocs:registerComponent({
name = "MyComponent";
reducer = rocs.reducers.structure({
field = rocs.reducers.add;
});
check = t.interface({
field = t.number;
});
entityCheck = t.instanceIsA("BasePart");
tag = "MyComponentTag";
})
rocs:registerComponent
is used to register a component.
Component registration fields
Field | Type | Description | Required |
---|---|---|---|
name | string | The name of the component. Must be unique across all registered components. | ✓ |
reducer | function (values: Array) -> any |
A function that reduces component data into a reduced value. | |
check | function (value: any) -> boolean |
A function which is invoked to type check the reduced value after reduction. | |
entityCheck | function | A function which is invoked to ensure this component is allowed to be on this entity. | |
tag | string | A CollectionService tag. When added to an Instance, Rocs will automatically create this component on the Instance. | |
defaults | dict | Default values for fields within this component. | |
components | dict | Default meta-components for this component. | |
initialize | method | Called when the Aggregate is instantiated | |
destroy | method | Called when the Aggregate is destroyed | |
onAdded | method | Called when the component is added for the first time | |
onUpdated | method | Called when the component's reduced value is updated | |
onParentUpdated | method | called when the component this meta-component is attached to is updated. (Only applies to meta-components). | |
onRemoved | method | Called when the component is removed | |
shouldUpdate | function (a: any, b: any) -> boolean |
Called before onUpdated to decide if onUpdated should be called. |
All *method*s above are called with self
as their only parameter.
Aggregate methods and fields
The following fields are inherited from the base Aggregate class and must not be present in registered components.
get
component:get(...fields) -> any
local component = entity:getComponent("MyComponent")
local allData = component:get()
local field = component:get("field")
local nested = component:get("very", "nested", "field")
Retrieves a field from the current reduced value, or the entire thing if no parameters are given.
Short circuits and returns nil
if any value in the path to the last field is nil
.
local nestedField = component:get("one", "two", "three")
You can also get nested values from sub-tables in the component.
getOr
component:getOr(...fields, default)
local value = component:getOr("field", "default value if nil")
local value = component:getOr("field", function(field)
return "default value if nil"
end)
Similar to get
, except returns the last parameter if the given field happens to be nil
.
If the last parameter is a function, the function will be called and its return value will be returned.
getAnd
component:getAnd(...fields, callback)
local value = component:getAnd("field", function(field)
return field or "default value"
end)
Similar to get
, except the retrieved field is fed through the given callback and its return value is returned from getAnd
if the field is
non-nil.
If the field is nil
, then getAnd
always returns nil
and the callback is never invoked. This function is useful for
transforming a value before using it.
set
component:set(...fields, value) -> void
component:set("field", 1)
component:set("one", "two", "three", Rocs.None)
Sets a field on the base scope within component. If you want to set a field to nil
, you must use Rocs.None
instead of nil
.
dispatch
component:dispatch(eventName: string, ...params) -> void
Dispatch an event on this component. Invokes the callback of any listeners which are registered for this event name.
If there is a method on this component sharing the same name as eventName
, it is also invoked.
listen
component:listen(eventName: string, listener: callback) -> listener
Adds a listener for this specific event name. Works for custom events which are fired with dispatch
, and built-ins such as onAdded
and
onUpdated
.
Returns the passed listener.
removeListener
component:removeListener(eventName: string, listener: callback) -> void
Removes a previously registered listener from this component. Send the same function that you registered previously as listener
to unregister it.
data
data: any
The current reduced value from this component.
lastData
lastData: any
The previous reduced value from this component. This is only available during life cycle methods such as onUpdated
.
Built-in Operators
Rocs provides a number of composable reducer and reducer utilities, so you only have to spend time writing a function for when you need something very specific.
Reducers
Reducer | Description |
---|---|
last |
Returns the last value of the set. base scope components are always first, so last will be any non-base scope (unless the base component is
the only value) |
first |
Returns the first value of the set. Opposite of last . |
truthy |
Returns the first truthy value in the set (or nil if there is none) |
falsy |
Returns the first falsy value in the set (or nil if none) |
add |
Adds the values from the set together (for numbers) |
multiply |
Multiplies the values from the set together (for numbers) |
lowest |
Lowest value (for numbers) |
highest |
Highest value (for numbers) |
concatArray |
Concatenates arrays |
Reducer Utilities
structure
reducer = rocs.reducers.structure({
field = rocs.reducers.add;
});
Reduces a dictionary with a separate reducer for each field.
Accepts the default reducer to use for omitted properties as a secondary parameter. By default, Reducers.last
is used for omitted properties.
map
reducer = rocs.reducers.map(
rocs.reducers.structure({
one = rocs.reducers.multiply;
two = table.concat;
three = function (values)
return values[3]
end
})
)
Reduces a table, using the same reducer for each key.
concatString
reducer = rocs.reducers.concatString(" - ")
Concatenates strings with a given delimiter.
priorityValue
reducer = rocs.reducers.priorityValue(rocs.reducers.concatString(" - "))
Takes values in the form { priority: number, value: any }
and produces the value
with the highest priority
, reducing any values with
equivalent priorities through the given reducer. If the reducer is omitted, Reducers.last
is implicit.
exactly
reducer = rocs.reducers.exactly("some value")
Creates a reducer which always results in the same value.
try
reducer = rocs.reducers.try(
rocs.reducers.truthy,
rocs.reducers.exactly("Default")
)
Tries a set of reducer functions until one of them returns a non-nil value.
compose
reducer = rocs.reducers.compose(
rocs.reducers.structure({
base = rocs.reducers.last;
add = rocs.reducers.add;
mult = rocs.reducers.multiply;
}),
function (value)
return value.base + value.add * value.mult;
end
)
Composes a set of reducers together such that the return value from each is passed into the next. Uses the return value of the last reducer.
thisOr
reducer = rocs.reducers.thisOr(
rocs.reducers.truthy,
1
)
Runs the given reducer, and provide a default value in case that reducer returns nil.
lastOr
reducer = rocs.reducers.lastOr(1)
Returns the last non-nil value or a default value if there is none.
truthyOr
reducer = rocs.reducers.truthyOr(1)
Same as thisOr
, except the truthy
reducer is always used.
falsyOr
Same as truthyOr
, except for falsy values.
Comparators
Comparator | Description |
---|---|
reference |
Compares two values by reference. |
value |
Compares two objects by value with a deep comparison. |
near |
Compares two numbers and only allows an update if the difference is greater than 0.001. |
Comparator Utilities
structure
shouldUpdate = rocs.comparators.structure({
propertyOne = rocs.comparators.reference;
propertyTwo = rocs.comparators.near;
})
Compares tables with a different function for each field within the table. If any of the properties should update, the entire structure will update. Omitted properties are compared by reference.
within
shouldUpdate = rocs.comparators.within(1)
Allows an update only when the change is not within the given epsilon.
Rocs API
Types
componentResolvable
"componentResolvable" refers to any value which can resolve into a component. Specifically, this means either the component name as a string, or the component definition itself as a value. Either will work, but the string form is usually more ergonomic.
new
Rocs.new(name: string = "global"): rocs
Creats a new Rocs instance. Use name
if you are using Rocs within a library; for games the default of "global"
is fine.
registerComponent
rocs:registerComponent(definition: dictionary): definition
See "Registering a component"
registerComponentsIn
rocs:registerComponentsIn(instance: Instance): void
Calls registerComponent on the return value from all ModuleScripts inside the given instance.
registerLifecycleHook
rocs:registerLifecycleHook(lifecycle: string, hook: callback): void
Registers a callback which is called whenever the given life cycle method is invoked on any component. Callback is called with
(componentAggregate, stageName)
.
onAdded
onUpdated
onParentUpdated
onRemoved
global
registerComponentHook
rocs:registerComponentHook(component: componentResolvable, lifecycle: string, hook: callback): { disconnect: callback }
Same as registerLifecycleHook
, except only for a single component type.
getComponents
rocs:getComponents(component: componentResolvable): array<Aggregate>
Returns an array of all Aggregates of the given type in the entire Rocs instance.
Entities API
local entity = rocs:getEntity(workspace)
addComponent
entity:addComponent(component: componentResolvable, data: any, metaComponents: dict?): Aggregate, boolean
Adds a new component to this entity under the entity's scope.
If data
is nil then this is equivalent to removeComponent
.
Returns the associated Aggregate and a boolean indicating whether or not this component was new on this entity.
removeComponent
entity:removeComponent(component: componentResolvable): void
Removes the given component from this entity.
addBaseComponent
entity:addBaseComponent(component: componentResolvable, data: any, metaComponents: dict?): Aggregate, boolean
Similar to addComponent
, but with the special base scope.
removeBaseComponent
entity:removeBaseComponent(component: componentResolvable): void
Similar to removeBaseComponent
, but with the special base scope.
getComponent
entity:getComponent(component: componentResolvable): Aggregate?
Returns the Aggregate for the given component from this entity if it exists.
getAllComponents
entity:getAllComponents(): array<Aggregate>
Returns all Aggregates on this entity.
removeAllComponents
entity:removeAllComponents(): void
Removes all Aggregates on this entity.
getScope
entity:getScope(scope: any): Entity
Returns a new Entity linked to the same instance as this entity but with a new scope.
Built-in Middleware
- Mention how to use middleware
Replication
- Todo
Selectors
- Todo
Authors
Rocs was designed and created by evaera and buildthomas.