Skip to content

Pool

Recycles Instances you spawn constantly, and resets or fades pooled visual effects between lives.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Pool
Since
v0.1.0
Source
Pool.luau

Pool keeps Instances you spawn all the time (shell casings, tracers, hit sparks, damage numbers) and hands them back out, so Instance.new and Destroy become table operations. It manages lifetime only. release unparents an instance, acquire returns it exactly as it was released, and resetting position, transparency or effects is the caller’s job, because only the caller knows what its effect changes. Two static helpers, Pool.clearEffects and Pool.quietEffects, cover the reset and fade-out steps that pooled visuals share. TablePool does the same for plain tables, and Preload warms assets ahead of time instead of recycling them.

Creating and destroying an instance per shot is allocation the collector has to sweep; GcWatch shows it as churn (a stable heap with busy cycles). A pool keeps retired instances in an idle list and vends them again.

A pool is three structures:

  • Idle, an array used as a stack. acquire pops the most recently released instance and release pushes.
  • IdleSet, a set mirror of Idle, so detecting a double release is one lookup.
  • LiveSet and LiveCount, every instance currently checked out. Releasing something the pool never vended is an error, and destroy() reaches checked-out instances too.

acquire pops an idle instance, or calls Create when the stack is empty. A created instance is marked live only after Create returns, so a factory that throws leaves LiveCount unchanged. Before 0.9.0 the count went up first and a throwing factory raised it for good.

There is no cap and no automatic shrinking. The pool grows to your peak concurrent demand and stays there, which is the point: that peak is what you would have allocated anyway, and keeping it means never allocating it again. trim(keep) destroys idle instances past keep when you know demand has dropped; Dissolve trims its voxel pools when an effect hibernates.

release changes one property, Parent = nil, which stops rendering, physics and touch events. Nothing else is reset.

An instance destroyed while checked out (its model was destroyed, a cleanup ran twice) can never be parented again: a destroyed instance’s Parent is locked. Releasing it would put a dead object on the idle stack for the next acquirer. pool:forget(instance) removes it from LiveSet and lowers LiveCount without returning it to idle. Unknown instances are ignored.

A reused visual carries what its last life left behind: Trail segments stretching back to where it died, particles still in flight. Two helpers handle that.

Pool.clearEffects(instance) calls Clear() on every Trail and ParticleEmitter descendant (not on the instance itself). Call it after the first CFrame write of the new life, so no trail segment spans the old and the new position.

Pool.quietEffects(instance, hideBody?) lets a retiring visual fade instead of vanishing. It visits the instance and its descendants:

  • Every enabled Trail, ParticleEmitter, Fire, Smoke and Sparkles gets Enabled = false. New output stops; segments and particles already out run to the end of their lifetime.
  • With hideBody, every BasePart, Decal and Beam gets LocalTransparencyModifier = 1, which hides it on this client only.

It returns a restore function that puts back exactly what it changed: the effects it disabled go back on (an emitter authored off stays off) and each hidden surface gets its previous modifier. Call restore before release, so the next acquire starts from the authored state.

Projectiles and Hazards build their client visuals on these. In a ProjectileClient or HazardClient visual definition, OnAcquire(visual, serial) runs on every acquire after the visual is placed and parented, and defaults to Pool.clearEffects. A custom hook replaces the default, so call Pool.clearEffects(visual) inside it to keep the reset. Linger = seconds keeps a retired visual where it died before it goes back to the pool: ProjectileClient quiets it with hideBody (the body disappears, trails fade) and HazardClient quiets it without (the body stays for an OnRetire fade).

Hit sparks that burst, fade out and go back to the pool:

src/Client/Game/Controllers/SparkController.luau
--!strict
-- SparkController: pooled hit sparks that fade out instead of popping.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pool = require(ReplicatedStorage.ChloeKernel.Pool)
local BurstSeconds = 0.15 -- the emitter runs this long
local FadeSeconds = 0.6 -- then its particles expire before the release
local SparkController = {
Name = "SparkController",
}
local function makeSpark(): Instance
local Spark = Instance.new("Part")
Spark.Size = Vector3.one * 0.2
Spark.Transparency = 1
Spark.Anchored = true
Spark.CanCollide = false
Spark.CanQuery = false
Spark.CanTouch = false
local Emitter = Instance.new("ParticleEmitter")
Emitter.Rate = 60
Emitter.Lifetime = NumberRange.new(0.2, 0.4)
Emitter.Parent = Spark
return Spark
end
function SparkController.init(self: any, kernel: any)
self.Kernel = kernel
end
function SparkController.start(self: any)
local Kernel = self.Kernel
local Sparks = Pool.new({ Create = makeSpark, InitialSize = 16 }) -- built at boot, not mid-fight
local Scope = Kernel:createScope()
self.Sparks, self.Scope = Sparks, Scope
Scope:add(Kernel.Bus:subscribe("Weapon.Hit", function(_topic, position: Vector3)
local Spark = Sparks:acquire() :: BasePart
Spark.CFrame = CFrame.new(position) -- reset on acquire: the pool returns whatever the last user left
Spark.Parent = workspace
Pool.clearEffects(Spark) -- after the first CFrame write, so no particles carry over from the last hit
local Burst: any
Burst = Scope:add(Kernel.Scheduler:after(BurstSeconds, function()
Scope:remove(Burst)
local Restore = Pool.quietEffects(Spark) -- stop emitting; live particles run out
local Release: any
Release = Scope:add(Kernel.Scheduler:after(FadeSeconds, function()
Scope:remove(Release)
Restore() -- authored state back before anyone acquires it again
Sparks:release(Spark)
end))
end))
end))
end
function SparkController.stop(self: any)
if self.Scope then
self.Scope:destroy() -- the subscription and every pending timer
self.Sparks:destroy() -- idle and checked-out sparks
end
end
return SparkController

stop destroys the scope before the pool, so no pending timer can release into a destroyed pool.

Member Description
Pool.new({ Create, InitialSize? }) -> Pool Create: () -> Instance is the factory. InitialSize (default 0) builds that many instances now, parented to nil, on the idle stack.
pool:acquire() -> Instance Pops an idle instance, or calls Create when none is idle. The instance counts as live once it is returned.
pool:release(instance) Sets Parent = nil and pushes the instance onto the idle stack. Errors with instance was already released to this pool on a double release and instance was not acquired from this pool on a foreign instance, at the caller’s line.
pool:forget(instance) Drops a live instance that was destroyed or lost, without returning it to idle. Ignores instances that are not live. Added in 0.8.0.
pool:trim(keep) -> number Destroys idle instances beyond keep and returns how many it destroyed. Live instances are untouched.
pool:idleCount() -> number Instances on the idle stack.
pool.LiveCount Instances checked out. idleCount() + LiveCount is the pool’s footprint.
pool:destroy() Destroys every idle and every checked-out instance and clears all tracking.
Pool.clearEffects(instance) Calls Clear() on every Trail and ParticleEmitter descendant. Added in 0.9.0.
Pool.quietEffects(instance, hideBody?) -> () -> () Disables enabled Trails, ParticleEmitters, Fire, Smoke and Sparkles, and with hideBody sets LocalTransparencyModifier = 1 on BaseParts, Decals and Beams. Returns a function that restores exactly those changes. Added in 0.9.0.
  • Projectiles: ProjectileClient keeps one pool per visual definition (4 built up front) and a tracer pool (8 built up front) for the minimal visuals it draws when the full-visual cap is reached.
  • Hazards: HazardClient keeps one pool per visual definition (2 built up front).
  • Dissolve: one pool per voxel template (16 built up front), trimmed when a controller hibernates.
  • Simulation: the load harness pools its projectile visuals, so a stress run measures kernel cost instead of instance churn.
  • Effects with a fixed lifetime (casings, sparks, damage numbers) suit a Scheduler deadline for the release. Effects whose end depends on gameplay release from the code that owns that end; the pool has no timeout of its own.
  • Pool has no kernel dependency. It works in any script on either side, including inside processes.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.