-
Notifications
You must be signed in to change notification settings - Fork 0
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.luauFSM/Orchestrator/Core/Factory/init.luauFSM/Orchestrator/Core/Factory/Registry.luauFSM/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.
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).
ββββββββββββββββββββββββββ
β 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 β
ββββββββββββββββββββββββββββ
| 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 |
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- the entity is removed from the live registry immediately,
-
IsValidbecomesfalse, - server clients receive an
OnEntityPooledlifecycle command, - no cleanup hooks run,
- old references become stale immediately.
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.
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(...).
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 |
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.
Every entity instance carries a private _lifecycleToken.
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 endThis prevents stale listeners from a previous incarnation from mutating or unregistering the reused instance.
- stale
StateUpdatedreplication listeners, - stale deferred persistence saves,
- stale destroy callbacks from the old life.
- your own external tables holding the stale entity reference,
- unmanaged
task.spawn/task.delayclosures that never checkIsValid, - external physics/network systems you forgot to detach.
| 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 |
| 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 |
Registry.PoolEntity simply returns when the pool is full:
if #Registry.EntityPools[className] >= MAX_POOL_SIZE then
return
endThat means overflow instances are not kept for reuse.
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(...).
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.
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)
endlocal 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_002local 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- 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
_lifecycleTokenchecks in delayed/async logic.
- 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.
-
Replication Pipeline β lifecycle broadcasts:
OnEntityCreated,OnEntityPooled,OnEntityDestroyed -
Versioning β pooled reuse resets
Versionand clears_versionHistory - API: EntityPersistence β persistence timing for pooled vs fresh entities
-
API: BaseEntity β
Destroy,Manage,UpdateEntity
Quick Links: Home Β· Quick Start Β· API Reference Β· Architecture Β· Examples Β· Glossary
Copyright: Β© 2026 RBXStateMachine contributors Β· Repository Β· License information