-
Notifications
You must be signed in to change notification settings - Fork 0
API BaseEntity
BaseEntity is the framework's schema-driven data object. It wraps entity data in a proxy table, stages writes through __newindex, commits them with :UpdateEntity(), and exposes signals used by replication, persistence, and UI layers.
Important
This page documents FSM/Orchestrator/Core/Factory/BaseEntity.luau and the persistence path used by Factory / PersistenceManager.
A BaseEntity instance is not just a plain Lua table. It is a proxy with four important layers:
- Schema - defines legal properties and their metadata
-
Pending + _cache - uncommitted writes staged through
entity.Field = value - Data - committed authoritative values
-
Signals -
StateUpdatedandDestroyed
[entity.Health = 75] βββ [__newindex validation] βββ [Pending + _cache] βββ [UpdateEntity]
βββ [Data commit] βββ [Version + _v] βββ [StateUpdated changes changedKeys]
βββ [ApplyChanges] βββ [Factory replication / persistence hooks]
__index does explicit nil checks so valid false values are preserved. Reads resolve in this order:
_cache[key]Data[key]Context[key]- subclass/class methods and fields
-
BaseEntitymethods - private properties (
Version,Mutable,OwnerId, etc.) Schema[key].Default
That lookup order is why a staged write becomes readable immediately even before UpdateEntity() commits it.
The schema controls what can be written through property syntax.
{
Type = "number" | "string" | "boolean" | "InstanceClassName" | "any",
Default = any?,
Persist = boolean?,
Replicate = boolean?,
}| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
Type |
string |
effectively yes | β | Expected typeof(...) result. For Roblox instances, value:IsA(def.Type) is also accepted. Use "any" to skip type checks. |
Default |
any? |
no | nil |
Returned by __index when the property has no cached, committed, or context value. |
Persist |
boolean? |
no | false |
Included by Serialize() and accepted by Deserialize(). |
Replicate |
boolean? |
no | false |
Eligible for network replication through Factory / Orchestrator. |
local DoorEntity = BaseEntity.Extend({
Name = "DoorEntity",
Mutable = true,
Replication = {
Enabled = true,
RateLimit = 10,
},
Schema = {
IsOpen = { Type = "boolean", Default = false, Persist = true, Replicate = true },
Health = { Type = "number", Default = 100, Persist = true, Replicate = true },
Label = { Type = "string", Default = "Door" },
Model = { Type = "Model" },
Extra = { Type = "any" },
},
})BaseEntity.Initialize(Util: any) -> ()One-time module initialization. Factory calls this during Factory.Initialize(...).
-
@reads:Initialized -
@writes: module logger, signal reference, settings cache
BaseEntity.new(params: {
Name: string,
OwnerId: string?,
Context: {[any]: any}?,
}, class: any?) -> anyLow-level constructor that creates the proxy object and its private backing table.
| Field | Type | Required | Default | Details |
|---|---|---|---|---|
Name |
string |
yes | β | Logger / entity class display name. |
OwnerId |
string? |
no | nil |
Used as the logger OperationId. |
Context |
table? |
no | {} |
Non-schema data stored outside committed entity state. |
class |
any? |
no | nil |
Subclass definition whose Schema, Mutable, and methods should be surfaced by the proxy. |
- A proxy table exposing
StateUpdated,Destroyed,_privateProperties, andGetRawData().
-
@reads:class.Definition.Schema,class.Definition.Mutable -
@writes: proxy signals, pending/data/context stores, version state, cleanup list, logger
local rawEntity = BaseEntity.new({
Name = "LooseEntity",
OwnerId = "player-42",
Context = { SpawnedAt = os.clock() },
})BaseEntity.Extend(extensionParams: {
Name: string,
Schema: {[string]: any}?,
Mutable: boolean?,
Replication: {
Enabled: boolean?,
RateLimit: number?,
}?,
}) -> anyBuilds a subclass table whose .new(params) constructor:
- creates the proxy with
BaseEntity.new(...) - pre-populates
Datafromparams.InitialDataif provided - copies replicated
_vintoVersionif present - calls
GetContext(self, params)if the subclass implements it - merges the returned context entries via
SetContext
| Field | Type | Details |
|---|---|---|
Name |
string |
Class name / logger name. |
Schema |
table? |
Property definitions used by __newindex, Serialize, and replication filters. |
Mutable |
boolean? |
Controls whether UpdateEntity() is allowed. Default is false. |
Replication |
table? |
Optional class-level replication policy used by Factory when wiring StateUpdated. |
- A subclass table inheriting from
BaseEntity.
local FSM = require(game:GetService("ReplicatedStorage").RBXStateMachine)
local BaseEntity = FSM.Orchestrator.Factory.BaseModules.BaseEntity
local DoorEntity = BaseEntity.Extend({
Name = "DoorEntity",
Mutable = true,
Replication = {
Enabled = true,
RateLimit = 5,
},
Schema = {
IsOpen = { Type = "boolean", Default = false, Persist = true, Replicate = true },
LastUserId = { Type = "number", Persist = true },
Model = { Type = "Model" },
},
})
function DoorEntity:GetContext(params)
return {
Model = params.Context and params.Context.Model,
Hinges = {},
}
end
function DoorEntity:ApplyChanges(changes)
if changes.IsOpen ~= nil and self.Model then
self.Model:SetAttribute("IsOpen", changes.IsOpen)
end
end| Hook | When it runs | Purpose |
|---|---|---|
GetContext(self, params) |
during subclass .new(...)
|
Build derived runtime context after InitialData is already visible through proxy reads |
ApplyChanges(self, changes) |
after successful UpdateEntity()
|
Apply visual / physical side effects |
Cleanup(self) |
during Destroy() hierarchy walk |
Release subclass resources |
The instance proxy deliberately preserves falsy committed values. Example:
entity.IsOpen = false
entity:UpdateEntity()
print(entity.IsOpen) -- false, not the schema default, not Context fallbackProperty assignment does not immediately write committed data.
entity.Health = 75
print(entity.Health) -- 75 (from _cache)
print(entity:GetRawData().Health) -- nil until UpdateEntity()__newindex rules:
- destroyed entities ignore writes and log a warning
- unknown schema keys are rejected and log a warning
-
ApplyChangesis the only non-schema field explicitly allowed to be assigned directly -
nilwrites are allowed and tracked through_pendingKeys - type mismatches are rejected unless the schema type is
"any"or the value is anInstancesatisfying:IsA(def.Type)
BaseEntity:Log(params: {
Level: string,
Message: string,
}) -> ()Writes through the entity-local logger.
-
@reads:_privateProperties.OwnerId -
@writes: logger history / output
BaseEntity:DefineSchema(schema: {[string]: any}) -> ()Replaces the active schema at runtime.
- Nothing.
-
@reads: none -
@writes:_privateProperties.Schema
entity:DefineSchema({
Health = { Type = "number", Default = 100 },
Shield = { Type = "number", Default = 0 },
})BaseEntity:SetContext(key: any, value: any) -> ()Stores non-schema data under _privateProperties.Context.
-
@reads: none -
@writes:_privateProperties.Context[key]
entity:SetContext("OwnerPlayer", player)BaseEntity:Manage(
object: Instance | RBXScriptConnection | (() -> ()) | { Destroy: (self: any) -> () }
) -> anyRegisters something to be cleaned up by Destroy().
-
@reads: none -
@writes:_privateProperties._cleanupTasks
entity:Manage(workspace.ChildAdded:Connect(function(child)
print("Child observed", child.Name)
end))BaseEntity:Serialize() -> {[string]: any}Builds a table containing only schema fields with Persist = true and non-nil current values.
- Table of persistent state suitable for
EntityPersistence.Save(...).
-
@reads: schema definitions, current property values through proxy reads -
@writes: none
local payload = entity:Serialize()
print(payload.IsOpen, payload.Health)BaseEntity:Deserialize(data: {[string]: any}) -> ()Routes incoming persistent data through __newindex so schema validation still applies.
| Parameter | Type | Details |
|---|---|---|
data |
table |
Only keys marked Persist = true are accepted. |
- Nothing.
-
@reads:_privateProperties.Schema -
@writes: staged pending values via__newindex
entity:Deserialize({
IsOpen = true,
Health = 45,
})
-- still staged until committed
entity:UpdateEntity()Caution
Deserialize() does not call UpdateEntity() for you. It stages values. If you need ApplyChanges() and StateUpdated immediately, call UpdateEntity() afterward.
BaseEntity:UpdateEntity(lockingCallerId: string?) -> booleanCommits staged changes into authoritative data, increments Version, fires StateUpdated, and then calls ApplyChanges() with a copy of the changes minus _v.
UpdateEntity() only succeeds when all of the following are true:
- entity is still valid
- entity is mutable
- entity has at least one pending change
- entity is unlocked or
lockingCallerIdmatches the lock owner
-
trueifApplyChanges()ran without throwing -
falseif preconditions failed orApplyChanges()raised an error
-
@reads:Pending,_pendingKeys,IsValid,EntityLocked,Mutable, currentData -
@writes:Data,Pending,_pendingKeys,_cache,Version,_versionHistory,StateUpdated
StateUpdated fires with:
-
changes- table of committed values plus_v = Version -
changedKeys- array of keys that changed, built from the pre-commit pending snapshot
entity.Health = 90
entity.IsOpen = nil -- explicit clear supported
local ok = entity:UpdateEntity()
if ok then
print("Committed version", entity.Version)
endlocal lockId = "door-controller"
if entity:AcquireLock(lockId) then
entity.IsOpen = true
entity.LastUserId = 123456
entity:UpdateEntity(lockId)
entity:ReleaseLock(lockId)
endBaseEntity:ApplyChanges(changes: {[string]: any}) -> ()Virtual hook. Override this in subclasses to make committed data affect instances, UI, attachments, sounds, and other side effects.
| Parameter | Type | Details |
|---|---|---|
changes |
table |
Copy of committed changes with _v stripped out. Nil clears are preserved. |
- Nothing.
-
@reads: subclass-defined -
@writes: subclass-defined
function DoorEntity:ApplyChanges(changes)
if changes.IsOpen ~= nil and self.Model then
self.Model:SetAttribute("IsOpen", changes.IsOpen)
end
if changes.Health ~= nil and self.Model then
self.Model:SetAttribute("Health", changes.Health)
end
endBaseEntity:Cleanup() -> ()Virtual destroy hook. Override in subclasses if they own non-managed resources.
BaseEntity:GetValidProperties() -> {[string]: any}Returns the current schema table.
-
@reads:_privateProperties.Schema -
@writes: none
BaseEntity:AcquireLock(callerId: string) -> booleanAttempts to claim exclusive commit ownership.
-
trueif the lock was acquired -
falseif already locked orcallerIdis missing
-
@reads:_privateProperties.EntityLocked -
@writes:_privateProperties.EntityLocked
BaseEntity:ReleaseLock(callerId: string) -> booleanReleases the lock if callerId matches the current owner.
-
trueif released -
falseif unlocked or the caller does not own it
-
@reads:_privateProperties.EntityLocked -
@writes:_privateProperties.EntityLocked
BaseEntity:Destroy() -> ()Invalidates the entity, traverses the class hierarchy collecting Cleanup hooks, runs managed cleanup tasks, fires Destroyed, and then destroys the signals on a deferred task.
-
@reads:_privateProperties.IsValid,_classhierarchy,_cleanupTasks -
@writes:_cleanupTasks,IsValid, pending/cache tables,Destroyed,StateUpdated
entity:Destroy()-
Destroy()is idempotent; a destroyed entity ignores future property writes. - Cleanup traversal walks the subclass chain upward and runs every raw
Cleanupmethod it finds. - Cleanup tasks can be
Instance,RBXScriptConnection, function, or table-with-Destroy.
Every instance created by BaseEntity.new(...) gets a helper:
entity.GetRawData() -> {[string]: any}This returns committed Data without proxy fallback.
local committed = entity:GetRawData()
print(committed.Health)| Field | Type | Meaning |
|---|---|---|
StateUpdated |
Signal |
Fires after successful UpdateEntity() with (changes, changedKeys)
|
Destroyed |
Signal |
Fires during Destroy() before the signal objects themselves are destroyed |
Version |
number |
Incremented by every successful UpdateEntity() commit; also seeded from replicated _v when InitialData is provided |
Mutable |
boolean |
Whether UpdateEntity() is allowed |
IsValid |
boolean |
false after Destroy()
|
OwnerId |
string? |
Owner identifier used by logging |
Context |
table |
Runtime-only non-schema data |
Every successful UpdateEntity() does all of the following:
- increments
Version - writes
_v = Versioninto thechangesevent payload - appends a version-history snapshot
{ Version, Time, Changes = { key = { From, To } } } - trims history to
_versionHistoryMax(default50)
This version number is also used by the replication layer to:
- seed client entities from
InitialData._v - drop out-of-order packets
- warn on version gaps
- populate buffered client updates before entities finish spawning locally
entity.StateUpdated:Connect(function(changes, changedKeys)
print("Version", changes._v)
print("Changed keys", table.concat(changedKeys, ", "))
end)entity.NotInSchema = true -- warns and is ignoredentity.Health = "100" -- warns if schema says Type = "number"
entity.Model = workspace.Part -- warns if schema says Type = "Model"entity.LastUserId = nil
entity:UpdateEntity()Nil writes are tracked through _pendingKeys, which means clears survive the commit pipeline and appear in changes and changedKeys.
BaseEntity itself does not talk directly to DataStore. Instead it provides hooks consumed by the persistence layer.
[Factory-wired StateUpdated] βββ [PersistenceManager.SaveState] βββ [EntityPersistence.Save]
βββ [entity:Serialize] βββ [JSON payload] βββ [DataStoreEntity.SetAsync]
[Factory.CreateEntity Persistent=true] βββ [PersistenceManager.LoadState] βββ [EntityPersistence.Load]
βββ [entity:Deserialize] βββ [Pending + _cache]
| Method | Used by | Behavior |
|---|---|---|
Serialize() |
save pipeline | emits only Persist = true fields |
Deserialize(data) |
load pipeline | stages only Persist = true fields through the proxy |
Caution
Because Deserialize() stages values rather than committing them, persistent data loaded through PersistenceManager is immediately readable through _cache, but ApplyChanges() and StateUpdated do not run until something later commits those values with UpdateEntity().
Factory also listens to StateUpdated and only forwards keys whose schema definition has Replicate = true. If the class definition contains Replication = { RateLimit = N }, those deltas are batched at N updates per second.
local entity = DoorEntity.new({
Name = "DoorEntity",
OwnerId = "front-door",
Context = { Model = workspace.FrontDoor },
})
entity.IsOpen = true
entity.Health = 100
entity:UpdateEntity()local replicatedDoor = DoorEntity.new({
Name = "DoorEntity",
OwnerId = "front-door",
Context = { Model = workspace.FrontDoor },
InitialData = {
IsOpen = true,
Health = 50,
_v = 8,
},
})
print(replicatedDoor.IsOpen) -- true from committed Data
print(replicatedDoor.Version) -- 8entity.StateUpdated:Connect(function(changes, changedKeys)
if table.find(changedKeys, "Health") then
print("New health", changes.Health)
end
end)-
Property assignment is only staging.
entity.Health = 5does not mutate committedDatauntilUpdateEntity()succeeds. -
Deserialize()also stages. Persistence loads do not automatically callApplyChanges(). -
Immutable entities cannot commit. If
Mutableis false,UpdateEntity()logs an error and returnsfalse. - Unknown schema keys are dropped. This protects against accidental typos and stray replication data.
-
falsevalues are preserved. The proxy does not useorchains for reads, so booleans work correctly. -
Locks are advisory but enforced at commit time. Callers can still stage writes while another owner holds the lock, but
UpdateEntity(lockingCallerId)will fail unless the caller ID matches. - Destroyed entities remain tables, but invalid. Reads may still resolve stale data; writes are rejected.
-
Cleanup hooks bubble up the class chain. If multiple inherited classes implement
Cleanup, all of them are collected and run. -
GetRawData()bypasses staged writes. Use it when you need committed state only. - Replication and persistence depend on Factory wiring. A standalone entity instance created outside Factory will not auto-save or auto-replicate just because it has a schema.
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information