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.
Mental model
Section titled “Mental model”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.acquirepops the most recently released instance andreleasepushes.IdleSet, a set mirror ofIdle, so detecting a double release is one lookup.LiveSetandLiveCount, every instance currently checked out. Releasing something the pool never vended is an error, anddestroy()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.
Lost instances
Section titled “Lost instances”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.
Effects on reused visuals
Section titled “Effects on reused visuals”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,SmokeandSparklesgetsEnabled = false. New output stops; segments and particles already out run to the end of their lifetime. - With
hideBody, everyBasePart,DecalandBeamgetsLocalTransparencyModifier = 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:
--!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 longlocal 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 Sparkend
function SparkController.init(self: any, kernel: any) self.Kernel = kernelend
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 endend
return SparkControllerstop destroys the scope before the pool, so no pending timer can release into a destroyed pool.
API reference
Section titled “API reference”| 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. |
Who uses pools
Section titled “Who uses pools”- Projectiles:
ProjectileClientkeeps 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:
HazardClientkeeps 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.
Design notes
Section titled “Design notes”- 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.