Start typing to search packages!
luau-object-pool
By @herilfiu
Roblox
Mirrored from Wallyluau-object-pool
A reusable object pool for Roblox games — and for anything else that runs Luau.
A pool is an allocator with its policy lifted out of it: a free list, a ledger of what is
checked out, and a set of replaceable hooks naming everything the allocator itself must not know.
Reuse Parts, Models, UI frames, or plain Luau tables through one generic, --!strict API.
local pool = ObjectPool.new(ReplicatedStorage.Assets.Bullet, 256)
local bullet = pool:Get(workspace.Projectiles) -- leases; allocates nothing on a hit
pool:Return(bullet) -- sanitizes, unparents, requeues
Features
- Generic over
T.BasePart,Model,Frame,Sound, or a plain table. The Instance behaviours are defaults reached through a runtimetypeoftest, not casts asserted overT. - Zero engine dependencies. The module names no
game, noInstance.new, notask, nowarn. Roblox reaches it through values you pass in, so the whole test suite runs headless under Lune. - No allocation on the lease path. A
Getthat hits the free list is one array read, one nil write and two counter increments. Notable.remove, no#, no closures, no yields. - Double returns are rejected, not tolerated — the single nastiest pooling bug, caught at source.
- Explicit lifecycle.
MaxIdlecaps retention,Trimgives memory back,Destroyis terminal. - Fails fast, at the line that caused it. Every hook and policy number is validated where the pool is built, not where it is later used, and each constructor names its own parameters — so a bad argument points at your call rather than at a frame inside the library.
- Strictly typed.
--!strictfrom line 1, with every public type exported for Luau LSP. - Dual ecosystem. Ships to both Wally and pesde from one source tree.
Contents
- Installation
- Quick start
- How it works
- Feature guide
- The master example: a projectile manager
- API reference
- Performance notes
- Gotchas
- Development
Installation
Wally
[dependencies]
ObjectPool = "herilfiu/luau-object-pool@^0.1.0"
wally install
pesde
pesde add herilfiu/luau_object_pool
pesde install
Requiring it
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)
Quick start
--!strict
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)
-- Route diagnostics to the engine's warning channel. Once, at startup.
ObjectPool.setReporter(warn)
-- A pool of 64 clones of an authored template.
local pool = ObjectPool.new(ReplicatedStorage.Assets.Impact, 64, function(part: BasePart)
part.Transparency = 0
part.Size = Vector3.one
end)
-- Lease one and place it. `Get` parents the instance at the target for you.
local impact = pool:Get(workspace.Effects)
impact.CFrame = CFrame.new(0, 10, 0)
-- Hand it back. Sanitize runs, then the part is unparented and requeued.
pool:Return(impact)
print(pool:IdleCount()) --> 64
How it works
A pool holds two structures and five hooks.
| Structure | What it is |
|---|---|
InactiveList / InactiveCount | The free list. LIFO, so the newest return is the next lease and the working set stays hot. |
ActiveList | The lease ledger. Weak-keyed, so an abandoned lease cannot pin a dead object for the session. |
| Hook | Answers | Default |
|---|---|---|
Factory | how is one made? | required |
Sanitize | what dirty state must be reset before reuse? | nothing |
Park | where does an idle item live? | Parent = nil if it is an Instance |
Dispose | how is one destroyed for good? | :Destroy() if it is an Instance |
Attach | what does Get's optional target mean? | Parent = target if it is an Instance |
…plus two numbers of policy, which are yours to state and the pool's to enforce:
| Policy | Meaning | Default |
|---|---|---|
MaxIdle | Retention ceiling. A return past it is disposed instead of pooled. | unbounded |
GrowthChunk | How many to build when a Get finds the free list empty. | 1 |
The value of a policy is application-specific, so it is supplied per pool. The enforcement belongs in one place — otherwise it reappears in every caller, differently each time.
Feature guide
Creating a pool
Three constructors, all of which funnel into one construction path.
From a template — the common case for authored assets:
local pool = ObjectPool.new(ReplicatedStorage.Assets.Bullet, 128, function(part: BasePart)
part.AssemblyLinearVelocity = Vector3.zero
end)
The template is never mutated, never parented and never handed out. The pool takes its diagnostics
name from the template's Name.
From a factory — for objects with no authored template, and the place an engine dependency belongs:
local attachments = ObjectPool.fromFactory("Attachment", function(): Attachment
return Instance.new("Attachment")
end, 32)
local trailPoints = ObjectPool.fromFactory("TrailPoint", function()
return { Position = Vector3.zero, Age = 0 }
end)
From a full config — every hook and both policy numbers:
local tracers = ObjectPool.fromConfig({
Name = "Tracer",
Factory = function(): Part
local part = Instance.new("Part")
part.Anchored = true
part.CanCollide = false
part.CanQuery = false
part.Material = Enum.Material.Neon
return part
end,
Sanitize = function(part: Part)
part.CFrame = CFrame.identity
part.Transparency = 0
end,
MaxIdle = 256,
GrowthChunk = 16,
Preallocate = 64,
})
Renting an object
Get always answers an item, growing the pool by GrowthChunk if the free list is empty.
The optional argument is handed to the Attach hook — which, by default, parents the instance.
local part = pool:Get(workspace.Effects) -- parented for you
local orphan = pool:Get() -- you place it yourself
TryGet never allocates. It answers nil on an empty pool, which is the seam for a caller whose
"make a new one" path is not the pool's Factory:
local rig = pool:TryGet()
if rig == nil then
rig = templateRegistry:Build(kind)
pool:Adopt(rig) -- now a later Return is a legal return
end
Returning an object
pool:Return(part) --> true when the part is now idle in the pool
Return runs Sanitize, then Park, then requeues — unless the pool already holds MaxIdle
items, in which case the item is disposed instead. It answers false in every case where the item
did not end up idle in the pool.
If you destroyed the object yourself, hand it back with Discard, never Return:
if part:IsDescendantOf(workspace) then
pool:Return(part)
else
pool:Discard(part) -- disposes it and drops it from the ledger
end
Pre-allocating and trimming
pool:Expand(200) --> 200 ; build ahead of a burst, off the frame the player is looking at
pool:Trim(32) --> n ; dispose idle items down to a warm floor
pool:Trim(0) --> n ; give it all back at round teardown
Expand is deliberately not clamped to MaxIdle: an explicit pre-warm is a statement of intent.
Trim never touches leased items.
Adopting foreign objects
local rig = somethingElseBuiltThis()
pool:Adopt(rig) --> true ; the pool now considers it leased
pool:Return(rig) --> true ; …so this is a legal return, not a rejected one
Adopt refuses an item that is already leased or already idle — either would be the double-lease
the pool exists to prevent.
Non-Instance pools
T is genuinely unconstrained. No Instance semantics are involved unless typeof(item) actually
answers "Instance":
type Particle = { Position: Vector3, Velocity: Vector3, Life: number }
local particles = ObjectPool.fromConfig({
Name = "Particle",
Factory = function(): Particle
return { Position = Vector3.zero, Velocity = Vector3.zero, Life = 0 }
end,
Sanitize = function(particle: Particle)
particle.Position = Vector3.zero
particle.Velocity = Vector3.zero
particle.Life = 0
end,
Preallocate = 512,
})
Teardown
pool:Destroy()
Disposes everything the pool owns — idle and still-leased — and retires it. This is terminal:
a later Get, Expand or Adopt raises. Return and Discard keep working, so an in-flight
lease arriving after teardown is disposed rather than leaked. Calling it twice is a no-op.
Diagnostics
ObjectPool.setReporter(warn) -- once, at startup: route messages to the engine
local stats = pool:Stats()
print(`{stats.Idle} idle, {stats.Leased} out, {stats.Created} ever built`)
for _, row in ObjectPool.snapshot() do
print(`{row.Name}#{row.Serial}: {row.Stats.Idle} idle / {row.Stats.Leased} out`)
end
Created climbing while Leases climbs with it means nothing is being reused. Rejected above
zero is always a caller bug.
The sink's type is exported as ObjectPool.Reporter, for a reporter you keep in a variable:
local collect: ObjectPool.Reporter = function(message: string)
table.insert(log, message)
end
ObjectPool.setReporter(collect)
ObjectPool.setReporter(nil) -- back to the default, `print`
The master example: a projectile manager
A complete server-side weapon system. It shows the whole lifecycle in one place: two pools (one for
the visible parts, one for the state records), a warm pre-allocation at start-up, high-frequency
renting and returning inside RunService.Heartbeat, cleanup when an owner leaves mid-flight, a
retention floor at round teardown, and a terminal Destroy on shutdown.
--!strict
-- ServerScriptService/ProjectileService.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)
--- One projectile in flight. `Part` is optional because `Sanitize` nils it on return: a pooled
--- record that still points at a BasePart keeps that part alive, which is exactly the leak a pool
--- is supposed to prevent.
type Projectile = {
Part: BasePart?,
Origin: Vector3,
Velocity: Vector3,
Elapsed: number,
Lifetime: number,
Damage: number,
Owner: Player?,
}
local MAX_LIFETIME = 3
local GRAVITY = Vector3.new(0, -60, 0)
local WARM_COUNT = 256
local IDLE_CEILING = 512
local ProjectileService = {}
ObjectPool.setReporter(warn)
local container = Instance.new("Folder")
container.Name = "Projectiles"
container.Parent = Workspace
-- Allocated once and reused for every cast: a fresh RaycastParams per frame is exactly the garbage
-- a pool exists to stop producing.
local castParams = RaycastParams.new()
castParams.FilterType = Enum.RaycastFilterType.Exclude
castParams.FilterDescendantsInstances = { container }
--------------------------------------------------------------------------------
-- Pools
--------------------------------------------------------------------------------
local partPool = ObjectPool.fromConfig({
Name = "ProjectilePart",
Factory = function(): BasePart
local part = Instance.new("Part")
part.Size = Vector3.new(0.2, 0.2, 2)
part.Material = Enum.Material.Neon
part.Color = Color3.fromRGB(255, 200, 80)
part.Anchored = true
part.CanCollide = false
part.CanQuery = false
part.CanTouch = false
part.CastShadow = false
return part
end,
Sanitize = function(part: BasePart)
part.CFrame = CFrame.identity
part.Transparency = 0
end,
MaxIdle = IDLE_CEILING,
GrowthChunk = 32,
Preallocate = WARM_COUNT,
})
local recordPool = ObjectPool.fromConfig({
Name = "ProjectileRecord",
Factory = function(): Projectile
return {
Part = nil,
Origin = Vector3.zero,
Velocity = Vector3.zero,
Elapsed = 0,
Lifetime = MAX_LIFETIME,
Damage = 0,
Owner = nil,
}
end,
Sanitize = function(record: Projectile)
-- Drop every reference the record holds. Numbers can stay dirty; references cannot.
record.Part = nil
record.Owner = nil
end,
MaxIdle = IDLE_CEILING,
GrowthChunk = 32,
Preallocate = WARM_COUNT,
})
--- Live projectiles, as a dense array with swap-remove, so retiring one is O(1) and the array never
--- shrinks its allocation.
local live: { Projectile } = {}
local liveCount = 0
--------------------------------------------------------------------------------
-- Firing and retiring
--------------------------------------------------------------------------------
--- Leases a part and a record and puts a projectile in the air.
function ProjectileService.Fire(owner: Player, origin: Vector3, direction: Vector3, speed: number, damage: number)
local part = partPool:Get(container)
local record = recordPool:Get()
record.Part = part
record.Origin = origin
record.Velocity = direction.Unit * speed
record.Elapsed = 0
record.Lifetime = MAX_LIFETIME
record.Damage = damage
record.Owner = owner
part.CFrame = CFrame.lookAlong(origin, direction)
liveCount += 1
live[liveCount] = record
end
--- Hands both leases back and swap-removes the record from the live array.
local function retire(index: number)
local record = live[index]
local part = record.Part
if part ~= nil then
if part:IsDescendantOf(container) then
partPool:Return(part)
else
-- Something else destroyed or reparented it. `Discard` disposes it and drops it from the
-- ledger, so it never re-enters the free list in an unknown state.
partPool:Discard(part)
end
end
recordPool:Return(record)
live[index] = live[liveCount]
live[liveCount] = nil
liveCount -= 1
end
--------------------------------------------------------------------------------
-- The frame loop
--------------------------------------------------------------------------------
RunService.Heartbeat:Connect(function(deltaTime: number)
-- Backwards, because `retire` swap-removes: walking forwards would skip the record that was
-- moved into the slot just vacated.
for index = liveCount, 1, -1 do
local record = live[index]
local part = record.Part
if part == nil then
retire(index)
continue
end
record.Elapsed += deltaTime
record.Velocity += GRAVITY * deltaTime
local from = part.Position
local step = record.Velocity * deltaTime
local hit = Workspace:Raycast(from, step, castParams)
if hit ~= nil then
ProjectileService.OnHit(record, hit)
retire(index)
elseif record.Elapsed >= record.Lifetime then
retire(index)
else
part.CFrame = CFrame.lookAlong(from + step, record.Velocity)
end
end
end)
--- Damage resolution lives here so the loop above stays about motion.
function ProjectileService.OnHit(record: Projectile, hit: RaycastResult)
local character = hit.Instance:FindFirstAncestorOfClass("Model")
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
if humanoid ~= nil and humanoid.Health > 0 then
humanoid:TakeDamage(record.Damage)
end
end
--------------------------------------------------------------------------------
-- Cleanup
--------------------------------------------------------------------------------
-- An owner leaving mid-flight is the classic lingering-reference bug: the record would keep a
-- Player alive for as long as the projectile lived. Retire their shots instead.
Players.PlayerRemoving:Connect(function(player: Player)
for index = liveCount, 1, -1 do
if live[index].Owner == player then
retire(index)
end
end
end)
--- Called at round teardown. Every projectile comes home, and the pools give back everything above
--- a warm floor — a lobby should not pay for the busiest moment of the last round.
function ProjectileService.EndRound()
for index = liveCount, 1, -1 do
retire(index)
end
partPool:Trim(64)
recordPool:Trim(64)
print(`[Projectiles] parts {partPool:IdleCount()} idle, records {recordPool:IdleCount()} idle`)
end
game:BindToClose(function()
partPool:Destroy()
recordPool:Destroy()
container:Destroy()
end)
return ProjectileService
What the pools bought
At 20 shots per second with a three-second lifetime, roughly 60 projectiles are in the air at any
moment and about 1,200 Instance.new calls per minute are avoided. The steady-state allocation of
the loop above is zero: the parts and the records are leased from the free list, the live array
reuses its slots, and RaycastParams is built once.
Worth watching, on a timer rather than per frame:
local stats = partPool:Stats()
if stats.Created > stats.Leases // 4 then
warn(`[Projectiles] poor reuse: {stats.Created} built for {stats.Leases} leases`)
end
if stats.Rejected > 0 then
warn(`[Projectiles] {stats.Rejected} bad returns — something is returning twice`)
end
API reference
Everything the module exports, and nothing else.
Module members
ObjectPool.VERSION: string
The published package version, kept in step with wally.toml and pesde.toml.
ObjectPool.new<T>(template: T, preallocate: number?, sanitize: ((T) -> ())?): ObjectPool<T>
Creates a pool that clones template. The template is never mutated, parented or handed out.
template is duck-typed on having a Clone method rather than on being an Instance. The pool takes
its Name from the template's Name when the template is an Instance, and "cloned" otherwise.
| Parameter | Type | Meaning |
|---|---|---|
template | T | The object to clone. Must have a Clone method. |
preallocate | number? | How many clones to build up front. Defaults to 0. |
sanitize | ((T) -> ())? | Per-Return cleanup, run before the item re-enters the free list. |
Raises when the template is neither an Instance nor a table, when it has no Clone method, when
preallocate is present and is not a finite number, when sanitize is present and is not a function,
or — from the Factory, so at construction time if preallocate is set — when :Clone() answers
nil (an Instance with Archivable = false).
ObjectPool.fromFactory<T>(name: string, factory: () -> T, preallocate: number?, sanitize: ((T) -> ())?): ObjectPool<T>
Creates a pool around a caller-supplied constructor. This is where an engine dependency belongs.
| Parameter | Type | Meaning |
|---|---|---|
name | string | Diagnostics label for the pool. |
factory | () -> T | Produces a brand-new T. Must not yield. |
preallocate | number? | How many to build up front. Defaults to 0. |
sanitize | ((T) -> ())? | Per-Return cleanup. |
Raises when name is not a string, when factory is not a function, when preallocate is
present and is not a finite number, or when sanitize is present and is not a function. The wrapper
names its own parameters rather than letting fromConfig raise about a config field you never
wrote.
ObjectPool.fromConfig<T>(config: PoolConfig<T>): ObjectPool<T>
Creates a pool from the full configuration. new and fromFactory are wrappers over this, so there
is exactly one construction path.
Every field is validated here, at the one place a pool is built, rather than where it is later used —
a non-callable Sanitize or an infinite GrowthChunk would otherwise surface inside Return or
Get, frames away from the constructor that caused it.
Raises when:
| Field | Rejected when | Why it cannot wait |
|---|---|---|
config | not a table | — |
Factory | not a function | The one required field. |
Name | present and not a string | ObjectPool.snapshot sorts on it, and one number-named pool makes that comparison throw for every other pool in the VM too. |
MaxIdle | present and not a non-negative number | A negative ceiling disposes every return; NaN compares false against every count, silently removing the ceiling altogether. math.huge is legal and means unbounded. |
GrowthChunk | present and not a finite number | An infinite chunk is a Get that never returns. Floored and clamped to at least 1. |
Preallocate | present and not a finite number | Likewise, at construction. Clamped to 0 from below. |
Sanitize, Park, Dispose, Attach | present and not a function | These run under pcall on cleanup paths, where a non-callable one is swallowed rather than reported. |
If the Factory throws part-way through a Preallocate, the pool disposes what it had already built
before re-raising. The constructor never returns, so you are handed no pool to tear down yourself, and
the partial build would otherwise be stranded with nothing owning it.
ObjectPool.snapshot(): { PoolSnapshot }
Every live pool in this VM with its counters, sorted by Name then Serial. Destroyed pools and
garbage-collected pools do not appear. The registry is weak-keyed, so being observable never keeps
a pool alive.
ObjectPool.setReporter(nextReporter: ((message: string) -> ())?)
Routes this module's diagnostics somewhere. Pass nil to restore the default, print. The module
names no engine global, so it cannot call warn itself — Roblox consumers wire it up once at
start-up with ObjectPool.setReporter(warn).
Raises when nextReporter is neither a function nor nil.
Methods
Every method takes an explicit self, so pool:Method(…) and ObjectPool.Method(pool, …) are both
valid.
pool:Get(attachTo: any?): T
Leases an item, growing the pool by GrowthChunk first if it is exhausted. If attachTo is given,
the pool's Attach hook runs against it — parenting the Instance, by default. Never answers nil.
Raises when the pool has been destroyed, or when the Factory produced no item.
pool:TryGet(attachTo: any?): T?
Leases an idle item without ever allocating. Answers nil when the free list is empty. Does not
check the destroyed flag: a destroyed pool has an empty free list, so it simply answers nil.
pool:Return(item: T): boolean
Runs Sanitize, then Park, then requeues the item. Answers true only when the item is now idle
in the pool.
Answers false and warns when the item is not currently leased from this pool (a double return, or
an item from a different pool), incrementing Stats().Rejected. Answers false and disposes the
item when Sanitize or Park throws, or when the pool already holds MaxIdle items. Answers
false and disposes the item silently when the pool has been destroyed — an in-flight lease
coming home during teardown is ordinary, not a bug.
pool:Adopt(item: T): boolean
Registers an item this pool did not build as leased from it, so a later Return is legal. Answers
false when item is nil, false when it is already leased, and false with a warning when it is
already idle. O(n) over the free list, deliberately — Adopt is a cold path, and adopting an item
that is already idle would hand the same one to two owners on the next two Gets.
Raises when the pool has been destroyed.
pool:Discard(item: T): boolean
Disposes a leased item instead of recycling it, and drops it from the ledger. Answers false when
the item was not leased from this pool. The path for an item you destroyed yourself.
pool:Expand(amount: number): number
Grows the free list by amount items via Factory and answers how many were built. amount is
floored and clamped to zero from below. Deliberately not clamped to MaxIdle.
A Factory that throws propagates, and the items already built stay in the pool.
Raises when amount is not finite, when the pool has been destroyed, or when the Factory
answers nil — storing that would leave a hole in the free list for a later Get to pop.
pool:Trim(maxIdle: number): number
Disposes idle items until at most maxIdle remain, and answers how many were disposed. Leased items
are untouched.
Raises when maxIdle is not finite.
pool:IdleCount(): number
How many items are pooled and available. O(1) — safe to call per frame.
pool:LeasedCount(): number
How many items are currently checked out. O(n) by necessity: the ledger is weak-keyed, so a maintained counter would drift upward as abandoned leases were collected. Diagnostics only.
pool:IsLeased(item: T): boolean
Whether item is currently leased from this pool.
pool:Stats(): PoolStats
The pool's counters. Allocates one table per call and includes LeasedCount, so read it on a timer
rather than per frame.
pool:Destroy()
Disposes every item the pool owns — idle and still-leased — clears both structures, and retires the pool. Terminal, and idempotent. Counters are kept, because they describe the pool's life rather than its contents.
Exported types
export type PoolConfig<T> = {
Factory: () -> T,
Sanitize: ((T) -> ())?,
Park: ((T) -> ())?,
Dispose: ((T) -> ())?,
Attach: ((T, any) -> ())?,
MaxIdle: number?,
GrowthChunk: number?,
Preallocate: number?,
Name: string?,
}
export type PoolStats = {
Leases: number, -- successful Get/TryGet calls, plus accepted Adopts
Created: number, -- items the Factory has produced
Recycled: number, -- returns accepted back into the free list
Disposed: number, -- destroyed for good: overflow, Discard, Trim, failed sanitize, Destroy
Rejected: number, -- returns refused — always a caller bug
Idle: number, -- currently pooled and available
Leased: number, -- currently checked out
}
export type PoolSnapshot = {
Name: string,
Serial: number, -- monotonic per VM; two pools may share a name, never a serial
Stats: PoolStats,
}
-- The sink `ObjectPool.setReporter` installs. Named so a consumer can annotate their own.
export type Reporter = (message: string) -> ()
ObjectPool<T> is exported as a flat record rather than the
typeof(setmetatable(…)) & { methods } idiom, so that a pool read back out of a
{ [string]: ObjectPool<Model> } registry still satisfies its own methods, and so the analyser
never has to unify a twelve-method intersection at every call site.
Use it to annotate a pool you store:
local pool: ObjectPool.ObjectPool<BasePart>? = nil
Attach types its target as any rather than unknown on purpose: Luau checks function parameters
contravariantly, so a hook written as function(part: Part, target: Instance) is not assignable to
a field declared (T, unknown) -> (). any is the only annotation that lets you name your own
concrete target type.
Readable fields
Every field on a pool is readable and must be treated as read-only — each is an invariant the methods maintain together.
| Field | Type | Meaning |
|---|---|---|
Name | string | Diagnostics label. Defaults to "unnamed". |
Serial | number | Monotonic id, unique per VM. |
Factory, Sanitize, Park, Dispose, Attach | hooks | As configured, with defaults resolved. |
MaxIdle | number | Retention ceiling; math.huge when unbounded. |
GrowthChunk | number | Floored and clamped to at least 1. |
InactiveList | { T } | The free list. |
InactiveCount | number | How many entries of InactiveList are live. |
ActiveList | { [T]: boolean } | The weak-keyed lease ledger. |
Leases, Created, Recycled, Disposed, Rejected | number | Lifetime counters; also on Stats(). |
WarnedOverflow | boolean | Whether the MaxIdle warning has already fired. |
Destroyed | boolean | Whether Destroy has been called. |
Performance notes
- The lease path allocates nothing.
Geton a non-empty pool is one array read, one nil write and two counter increments. There is notable.remove(which itself pays#t), no#anywhere on a hot path —InactiveCountis maintained — and no closure per call. - The free list is LIFO. The newest return is the next lease, which keeps the working set in cache rather than cycling through every object the pool owns.
Returnpays onepcall. That is deliberate: aSanitizethat throws must not leave the ledger corrupted. The guarded call is made against a module-level function, so it allocates no closure.Stats()andLeasedCount()are O(n) in the number of leased items, andStats()allocates a table. Read them on a timer, never per frame.IdleCount()is O(1).Adoptis O(n) over the free list, deliberately — it is a cold path, and the scan is what stops the same item being handed to two owners.- No method yields. The absence of a suspension point is the atomicity: a lease cannot be
observed half-taken. A
Factory,Sanitize,Park,DisposeorAttachthat yields is a caller bug. - Pools are per-Luau-VM. A pool required inside a worker
Actoris that Actor's own, not a shared one, andGet/Returnon an Instance pool must run synchronized.
Gotchas
Returning twice. Tween.Completed fires on cancellation as well as completion, so the obvious
Completed:Once(returnToPool) idiom returns twice whenever anything cancels the tween. The pool
rejects the second return and warns; check Stats().Rejected.
Returning something you destroyed. On a destroyed Instance a property write, a method call and
Parent = nil all succeed silently — only assigning a real parent throws. So the default Park
accepts a dead Instance, it re-enters the free list, and the failure surfaces at a later Get. Use
Discard for anything you destroyed yourself.
Leaving references on a pooled object. A pooled record that still points at a Player or a
BasePart keeps it alive. Nil out every reference in Sanitize; numbers can stay dirty.
Expecting Sanitize to reset everything. The pool resets only what your hook resets. Anything
else comes back exactly as it went in — which is often what you want, and occasionally a surprise.
Forgetting setReporter. Diagnostics go to print by default, so they are easy to miss in
Studio's output. Call ObjectPool.setReporter(warn) once at start-up.
Development
Install the pinned toolchain with Rokit:
rokit install
All four must pass with zero warnings before any commit:
stylua --check src tests scripts
selene src tests scripts
luau-lsp analyze --platform=standard src tests scripts
lune run tests/run.luau
Tests live in tests/specs/ and use the dependency-free runner in tests/runner.luau. The suite
runs headless: tests/specs/instances.luau covers the Instance defaults against real Instances from
Lune's @lune/roblox, so the parenting, destruction and cloning paths are tested without Studio.
Three of the specs check things review is bad at and a diff hides entirely:
| Spec | Asserts |
|---|---|
portability.luau | src/init.luau names no engine global, and VERSION matches both manifests. |
package.luau | The published surface type-checks from a consumer's position, against every exported type. |
documentation.luau | The ObjectPool<T> record and the implementation agree, every public member and exported type is documented on both reference pages, and the docs name nothing the module does not have. |
Commits follow Conventional Commits, enforced by prek and
in CI, and drive Release Please.
License
MIT.
Package Details
Install command (Click to copy)
Version
0.1.0
License
MIT
Safe for commercial use
Automated license review — not legal advice.
