Skip to content

Pooling Strategy

iKryptonic edited this page May 1, 2026 · 3 revisions

Pooling Strategy

How RBXStateMachine recycles entity instances, what actually gets reset on reuse, and when pooling is the right tradeoff.

Important

Audit scope (2026-05): This page was verified against:

  • FSM/Orchestrator/init.luau
  • FSM/Orchestrator/Core/Factory/init.luau
  • FSM/Orchestrator/Core/Factory/Registry.luau
  • FSM/Orchestrator/Core/Factory/BaseEntity.luau

Pooling in RBXStateMachine is entity-instance reuse, not destroy-and-recreate. Orchestrator.PoolEntity(...) does not call Destroy() or Cleanup(); it invalidates the live entity, unregisters it, and stores the instance in Registry.EntityPools[className] for later reuse.


🎯 Overview

Pooling exists to avoid repeatedly allocating identical short-lived entities.

Typical fit:

  • projectiles,
  • impact VFX,
  • temporary hitboxes,
  • disposable NPC shells with lightweight state.

Bad fit:

  • player-owned entities,
  • persistent entities,
  • anything with heavy external resource ownership that must run Cleanup() to stay safe.

The pool is:

  • per entity class (Registry.EntityPools[className]),
  • LIFO (table.remove(pool)),
  • capped at 64 entries per class (MAX_POOL_SIZE = 64).

πŸ”„ Pool Flow

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Live registered entity β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Orchestrator.PoolEntity(entityId)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Mark IsValid = false β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Registry.UnregisterEntity(entityId)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Server broadcasts OnEntityPooled β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Registry.PoolEntity(className,   β”‚
β”‚ entity)                          β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Idle pooled instance β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Factory.CreateEntity same class β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Registry.GetPooledEntity(className)β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Reset internals + increment lifecycle token β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Apply new Context / InitialData β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Register entity under new EntityId β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Wire replication / persistence lifecycle β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
           β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Broadcast OnEntityCreated β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ”§ Public Entry Points

API Signature Source Purpose
Orchestrator.PoolEntity (entityId: string) -> boolean Orchestrator/init.luau Remove live entity from registry and push it into the pool
Registry.PoolEntity (className: string, entity: any) -> () Registry.luau Append to class pool if below cap
Registry.GetPooledEntity (className: string) -> any? Registry.luau Pop last pooled entity for reuse
Factory.CreateEntity (params: { EntityClass, EntityId?, Context?, Persistent?, PersistenceKey?, InitialData? }) -> any? Factory/init.luau Reuses pooled entity before allocating a new one

πŸ“‹ Exact Pooling Lifecycle

1. Pooling a live entity

Orchestrator.PoolEntity(entityId) does exactly this:

local entity = Registry.GetEntity(entityId)
if not entity then return false end

local className = entity.Name
entity._privateProperties.IsValid = false
Registry.UnregisterEntity(entityId)

if RunService:IsServer() then
    NetworkManager.BroadcastEntityCommand(entityId, "OnEntityPooled")
end

Registry.PoolEntity(className, entity)
return true

Important consequences

  • the entity is removed from the live registry immediately,
  • IsValid becomes false,
  • server clients receive an OnEntityPooled lifecycle command,
  • no cleanup hooks run,
  • old references become stale immediately.

2. Idle pooled state

While sitting in the pool, the instance:

  • is not registered in Registry.Entities,
  • keeps whatever unmanaged external resources you left attached,
  • keeps its Lua object identity,
  • will be reused only by the same entity class name.

Pool selection is last-in-first-out:

return table.remove(pool)

So the most recently pooled instance is reused first.


3. Reuse during Factory.CreateEntity(...)

Before constructing a brand-new entity, the factory checks:

local pooled = Factory.Registry.GetPooledEntity(entityClassName)

If a pooled entity exists, the factory resets and reuses it instead of calling EntityClass.new(...).


♻️ What Gets Reset on Reuse

When a pooled entity is reused, Factory.CreateEntity(...) performs these resets in order.

Field / subsystem Reset behavior
_lifecycleToken incremented by 1
IsValid set to true
Data table.clear(pp.Data)
Pending table.clear(pp.Pending)
_pendingKeys cleared or recreated
_cache cleared
Context cleared, then rebuilt from new params
Version set to 0 unless InitialData._v seeds it
EntityLocked cleared
Persistent set from new create params
_cleanupTasks cleared
_versionHistory cleared
StateUpdated signal old signal destroyed, new signal created
Destroyed signal old signal destroyed, new signal created
Context.EntityId overwritten with new entity id
InitialData copied into Data before re-registration
GetContext(...) output recomputed and merged back into context

What is not automatically cleaned up

Pooling does not call Destroy() or Cleanup(). That means these are your responsibility before pooling:

  • active RBXScriptConnections not stored in _cleanupTasks,
  • Instances still parented into the DataModel,
  • side tables / registries held outside the entity,
  • custom raw fields attached outside schema/context,
  • anything your Cleanup() override would normally release.

Caution

_cleanupTasks is cleared without being executed during reuse. If you added connections/instances with entity:Manage(...) and then pooled instead of destroying, those managed resources do not get auto-disposed.


πŸͺͺ Lifecycle Token

Every entity instance carries a private _lifecycleToken.

Why it exists

When the factory wires replication and destroy listeners, it captures the token from the current β€œlife” of the entity. Reused pooled entities increment the token before getting rewired.

Old callbacks check the token:

if entity._privateProperties._lifecycleToken ~= lifecycleToken then return end

This prevents stale listeners from a previous incarnation from mutating or unregistering the reused instance.

What it protects

  • stale StateUpdated replication listeners,
  • stale deferred persistence saves,
  • stale destroy callbacks from the old life.

What it does not protect

  • your own external tables holding the stale entity reference,
  • unmanaged task.spawn / task.delay closures that never check IsValid,
  • external physics/network systems you forgot to detach.

πŸ€” When to Pool vs Create Fresh

Situation Recommendation Why
Projectile / VFX / temporary hitbox Pool High churn, homogeneous schema, short lifetime
Short-lived NPC shell with explicit teardown method Pool Repeated spawn/despawn cost can dominate
Player entity Create fresh Player-specific state leakage risk is too high
Persistent entity (Persistent = true) Create fresh Pool reset + persistence load ordering is harder to reason about
Entity with heavy Cleanup() side effects Create fresh unless you manually teardown first Pooling skips Cleanup() entirely
Unique boss / singleton system entity Create fresh No reuse benefit
Entity carrying live Instance trees / joints / attachments Usually create fresh or explicitly detach first Easy to leak workspace state

⚠️ Failure Modes and Edge Cases

Orchestrator.PoolEntity(...)

Case Result
entity id not found returns false
pool already full (64) entity is dropped on the floor after unregistering; not re-added to pool
called on client via replicated OnEntityPooled works locally because broadcast is server-gated

Pool capacity overflow

Registry.PoolEntity simply returns when the pool is full:

if #Registry.EntityPools[className] >= MAX_POOL_SIZE then
    return
end

That means overflow instances are not kept for reuse.

You cannot β€œdestroy then pool” with the public API

Once entity:Destroy() runs, the entity is invalidated and unregistered. A later Orchestrator.PoolEntity(entityId) will fail because the entity is no longer in the registry.

If you need custom teardown before pooling, do it in your own method before calling PoolEntity(...).

Reused entity ids are different

A pooled object can come back under a completely different EntityId. Never keep the old id or old reference as if it still points to a live entity.


πŸš€ Real Code Examples

Example 1: Pool a bullet after manual teardown

local function ReleaseBullet(entityId: string)
    local bullet = Orchestrator.GetEntity(entityId)
    if not bullet then
        return
    end

    -- Manual teardown because PoolEntity will NOT call Cleanup().
    if bullet.Context and bullet.Context.Part then
        bullet.Context.Part.Parent = nil
    end

    Orchestrator.PoolEntity(entityId)
end

Example 2: Reuse happens automatically on the next create

local bullet = Orchestrator.CreateEntity({
    EntityClass = "BulletEntity",
    EntityId = "Bullet_001",
    Context = {
        OwnerId = script.Name,
        SpawnCFrame = CFrame.new(),
    },
})

-- ... later ...
Orchestrator.PoolEntity("Bullet_001")

-- Next create of the same class reuses the pooled instance.
local reused = Orchestrator.CreateEntity({
    EntityClass = "BulletEntity",
    EntityId = "Bullet_002",
    Context = {
        OwnerId = script.Name,
        SpawnCFrame = CFrame.new(0, 10, 0),
    },
})

print(reused.Context.EntityId) -- Bullet_002

Example 3: Guard stale async work

local function StartLifetimeTimer(entity)
    local myToken = entity._privateProperties._lifecycleToken

    task.delay(2, function()
        if not entity._privateProperties.IsValid then
            return
        end
        if entity._privateProperties._lifecycleToken ~= myToken then
            return -- same Lua object, different pooled lifetime
        end

        Orchestrator.PoolEntity(entity.Context.EntityId)
    end)
end

βœ… Best Practices

  • Write an explicit teardown/reset method for every pooled entity class.
  • Detach all live Instances before pooling.
  • Assume old references are dead immediately after PoolEntity(...).
  • Keep pooled schemas small and homogeneous.
  • Use pooling only where churn is measurably high.
  • Use _lifecycleToken checks in delayed/async logic.

❌ Anti-Patterns

  • Pooling player entities.
  • Pooling persistent save-backed entities casually.
  • Assuming Cleanup() ran.
  • Holding stale entity references in service tables.
  • Ignoring the 64-instance per-class cap.

πŸ”— See Also

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally