Skip to content

API BaseEntity

iKryptonic edited this page May 1, 2026 · 14 revisions

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.


Overview

A BaseEntity instance is not just a plain Lua table. It is a proxy with four important layers:

  1. Schema - defines legal properties and their metadata
  2. Pending + _cache - uncommitted writes staged through entity.Field = value
  3. Data - committed authoritative values
  4. Signals - StateUpdated and Destroyed

Write / commit pipeline

[entity.Health = 75] ──→ [__newindex validation] ──→ [Pending + _cache] ──→ [UpdateEntity]
                    ──→ [Data commit] ──→ [Version + _v] ──→ [StateUpdated changes changedKeys]
                    ──→ [ApplyChanges] ──→ [Factory replication / persistence hooks]

Read resolution order

__index does explicit nil checks so valid false values are preserved. Reads resolve in this order:

  1. _cache[key]
  2. Data[key]
  3. Context[key]
  4. subclass/class methods and fields
  5. BaseEntity methods
  6. private properties (Version, Mutable, OwnerId, etc.)
  7. Schema[key].Default

That lookup order is why a staged write becomes readable immediately even before UpdateEntity() commits it.


Schema System

The schema controls what can be written through property syntax.

Common property definition shape

{
    Type = "number" | "string" | "boolean" | "InstanceClassName" | "any",
    Default = any?,
    Persist = boolean?,
    Replicate = boolean?,
}

Supported fields

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.

Example schema

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" },
    },
})

Construction and Class Extension

BaseEntity.Initialize(Util)

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, class?)

BaseEntity.new(params: {
    Name: string,
    OwnerId: string?,
    Context: {[any]: any}?,
}, class: any?) -> any

Low-level constructor that creates the proxy object and its private backing table.

Parameters

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.

Returns

  • A proxy table exposing StateUpdated, Destroyed, _privateProperties, and GetRawData().

Side effects

  • @reads: class.Definition.Schema, class.Definition.Mutable
  • @writes: proxy signals, pending/data/context stores, version state, cleanup list, logger

Example

local rawEntity = BaseEntity.new({
    Name = "LooseEntity",
    OwnerId = "player-42",
    Context = { SpawnedAt = os.clock() },
})

BaseEntity.Extend(extensionParams)

BaseEntity.Extend(extensionParams: {
    Name: string,
    Schema: {[string]: any}?,
    Mutable: boolean?,
    Replication: {
        Enabled: boolean?,
        RateLimit: number?,
    }?,
}) -> any

Builds a subclass table whose .new(params) constructor:

  1. creates the proxy with BaseEntity.new(...)
  2. pre-populates Data from params.InitialData if provided
  3. copies replicated _v into Version if present
  4. calls GetContext(self, params) if the subclass implements it
  5. merges the returned context entries via SetContext

Parameters

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.

Returns

  • A subclass table inheriting from BaseEntity.

Example

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

Subclass hooks

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

Proxy Semantics

__index

The instance proxy deliberately preserves falsy committed values. Example:

entity.IsOpen = false
entity:UpdateEntity()
print(entity.IsOpen) -- false, not the schema default, not Context fallback

__newindex

Property 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
  • ApplyChanges is the only non-schema field explicitly allowed to be assigned directly
  • nil writes are allowed and tracked through _pendingKeys
  • type mismatches are rejected unless the schema type is "any" or the value is an Instance satisfying :IsA(def.Type)

Core Methods

:Log(params)

BaseEntity:Log(params: {
    Level: string,
    Message: string,
}) -> ()

Writes through the entity-local logger.

  • @reads: _privateProperties.OwnerId
  • @writes: logger history / output

:DefineSchema(schema)

BaseEntity:DefineSchema(schema: {[string]: any}) -> ()

Replaces the active schema at runtime.

Returns

  • Nothing.

Side effects

  • @reads: none
  • @writes: _privateProperties.Schema

Example

entity:DefineSchema({
    Health = { Type = "number", Default = 100 },
    Shield = { Type = "number", Default = 0 },
})

:SetContext(key, value)

BaseEntity:SetContext(key: any, value: any) -> ()

Stores non-schema data under _privateProperties.Context.

  • @reads: none
  • @writes: _privateProperties.Context[key]
entity:SetContext("OwnerPlayer", player)

:Manage(object)

BaseEntity:Manage(
    object: Instance | RBXScriptConnection | (() -> ()) | { Destroy: (self: any) -> () }
) -> any

Registers 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))

:Serialize()

BaseEntity:Serialize() -> {[string]: any}

Builds a table containing only schema fields with Persist = true and non-nil current values.

Returns

  • Table of persistent state suitable for EntityPersistence.Save(...).

Side effects

  • @reads: schema definitions, current property values through proxy reads
  • @writes: none

Example

local payload = entity:Serialize()
print(payload.IsOpen, payload.Health)

:Deserialize(data)

BaseEntity:Deserialize(data: {[string]: any}) -> ()

Routes incoming persistent data through __newindex so schema validation still applies.

Parameters

Parameter Type Details
data table Only keys marked Persist = true are accepted.

Returns

  • Nothing.

Side effects

  • @reads: _privateProperties.Schema
  • @writes: staged pending values via __newindex

Example

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.

:UpdateEntity(lockingCallerId?)

BaseEntity:UpdateEntity(lockingCallerId: string?) -> boolean

Commits staged changes into authoritative data, increments Version, fires StateUpdated, and then calls ApplyChanges() with a copy of the changes minus _v.

Preconditions

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 lockingCallerId matches the lock owner

Returns

  • true if ApplyChanges() ran without throwing
  • false if preconditions failed or ApplyChanges() raised an error

Side effects

  • @reads: Pending, _pendingKeys, IsValid, EntityLocked, Mutable, current Data
  • @writes: Data, Pending, _pendingKeys, _cache, Version, _versionHistory, StateUpdated

Event payload

StateUpdated fires with:

  1. changes - table of committed values plus _v = Version
  2. changedKeys - array of keys that changed, built from the pre-commit pending snapshot

Example

entity.Health = 90
entity.IsOpen = nil -- explicit clear supported

local ok = entity:UpdateEntity()
if ok then
    print("Committed version", entity.Version)
end

Example with lock ownership

local lockId = "door-controller"
if entity:AcquireLock(lockId) then
    entity.IsOpen = true
    entity.LastUserId = 123456
    entity:UpdateEntity(lockId)
    entity:ReleaseLock(lockId)
end

:ApplyChanges(changes)

BaseEntity:ApplyChanges(changes: {[string]: any}) -> ()

Virtual hook. Override this in subclasses to make committed data affect instances, UI, attachments, sounds, and other side effects.

Parameters

Parameter Type Details
changes table Copy of committed changes with _v stripped out. Nil clears are preserved.

Returns

  • Nothing.

Side effects

  • @reads: subclass-defined
  • @writes: subclass-defined

Example

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
end

:Cleanup()

BaseEntity:Cleanup() -> ()

Virtual destroy hook. Override in subclasses if they own non-managed resources.

:GetValidProperties()

BaseEntity:GetValidProperties() -> {[string]: any}

Returns the current schema table.

  • @reads: _privateProperties.Schema
  • @writes: none

:AcquireLock(callerId)

BaseEntity:AcquireLock(callerId: string) -> boolean

Attempts to claim exclusive commit ownership.

Returns

  • true if the lock was acquired
  • false if already locked or callerId is missing

Side effects

  • @reads: _privateProperties.EntityLocked
  • @writes: _privateProperties.EntityLocked

:ReleaseLock(callerId)

BaseEntity:ReleaseLock(callerId: string) -> boolean

Releases the lock if callerId matches the current owner.

Returns

  • true if released
  • false if unlocked or the caller does not own it

Side effects

  • @reads: _privateProperties.EntityLocked
  • @writes: _privateProperties.EntityLocked

:Destroy()

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.

Side effects

  • @reads: _privateProperties.IsValid, _class hierarchy, _cleanupTasks
  • @writes: _cleanupTasks, IsValid, pending/cache tables, Destroyed, StateUpdated

Example

entity:Destroy()

Important behavior

  • Destroy() is idempotent; a destroyed entity ignores future property writes.
  • Cleanup traversal walks the subclass chain upward and runs every raw Cleanup method it finds.
  • Cleanup tasks can be Instance, RBXScriptConnection, function, or table-with-Destroy.

Instance Helpers and Exposed Fields

GetRawData()

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)

Common exposed fields

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

Versioning and History

Every successful UpdateEntity() does all of the following:

  1. increments Version
  2. writes _v = Version into the changes event payload
  3. appends a version-history snapshot { Version, Time, Changes = { key = { From, To } } }
  4. trims history to _versionHistoryMax (default 50)

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

Example: observing versioned updates

entity.StateUpdated:Connect(function(changes, changedKeys)
    print("Version", changes._v)
    print("Changed keys", table.concat(changedKeys, ", "))
end)

Validation Rules

Unknown keys are rejected

entity.NotInSchema = true -- warns and is ignored

Type checks are strict

entity.Health = "100" -- warns if schema says Type = "number"
entity.Model = workspace.Part -- warns if schema says Type = "Model"

Nil clears are supported

entity.LastUserId = nil
entity:UpdateEntity()

Nil writes are tracked through _pendingKeys, which means clears survive the commit pipeline and appear in changes and changedKeys.


Persistence Hooks

BaseEntity itself does not talk directly to DataStore. Instead it provides hooks consumed by the persistence layer.

Save path

[Factory-wired StateUpdated] ──→ [PersistenceManager.SaveState] ──→ [EntityPersistence.Save]
                             ──→ [entity:Serialize] ──→ [JSON payload] ──→ [DataStoreEntity.SetAsync]

Load path

[Factory.CreateEntity Persistent=true] ──→ [PersistenceManager.LoadState] ──→ [EntityPersistence.Load]
                                      ──→ [entity:Deserialize] ──→ [Pending + _cache]

What Serialize() / Deserialize() are expected to do

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().

Replication hooks

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.


Real Examples

Basic mutable entity

local entity = DoorEntity.new({
    Name = "DoorEntity",
    OwnerId = "front-door",
    Context = { Model = workspace.FrontDoor },
})

entity.IsOpen = true
entity.Health = 100
entity:UpdateEntity()

Using InitialData in subclass construction

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) -- 8

Watching committed changes only

entity.StateUpdated:Connect(function(changes, changedKeys)
    if table.find(changedKeys, "Health") then
        print("New health", changes.Health)
    end
end)

Edge Cases & Gotchas

  1. Property assignment is only staging. entity.Health = 5 does not mutate committed Data until UpdateEntity() succeeds.
  2. Deserialize() also stages. Persistence loads do not automatically call ApplyChanges().
  3. Immutable entities cannot commit. If Mutable is false, UpdateEntity() logs an error and returns false.
  4. Unknown schema keys are dropped. This protects against accidental typos and stray replication data.
  5. false values are preserved. The proxy does not use or chains for reads, so booleans work correctly.
  6. 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.
  7. Destroyed entities remain tables, but invalid. Reads may still resolve stale data; writes are rejected.
  8. Cleanup hooks bubble up the class chain. If multiple inherited classes implement Cleanup, all of them are collected and run.
  9. GetRawData() bypasses staged writes. Use it when you need committed state only.
  10. 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.

Related Pages

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally