Forest Logo
search
package_2

luau-object-pool

By @herilfiu

Roblox

Mirrored from Wally

luau-object-pool

CI Wally Package License: MIT

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 runtime typeof test, not casts asserted over T.
  • Zero engine dependencies. The module names no game, no Instance.new, no task, no warn. Roblox reaches it through values you pass in, so the whole test suite runs headless under Lune.
  • No allocation on the lease path. A Get that hits the free list is one array read, one nil write and two counter increments. No table.remove, no #, no closures, no yields.
  • Double returns are rejected, not tolerated — the single nastiest pooling bug, caught at source.
  • Explicit lifecycle. MaxIdle caps retention, Trim gives memory back, Destroy is 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. --!strict from line 1, with every public type exported for Luau LSP.
  • Dual ecosystem. Ships to both Wally and pesde from one source tree.

Contents


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.

StructureWhat it is
InactiveList / InactiveCountThe free list. LIFO, so the newest return is the next lease and the working set stays hot.
ActiveListThe lease ledger. Weak-keyed, so an abandoned lease cannot pin a dead object for the session.
HookAnswersDefault
Factoryhow is one made?required
Sanitizewhat dirty state must be reset before reuse?nothing
Parkwhere does an idle item live?Parent = nil if it is an Instance
Disposehow is one destroyed for good?:Destroy() if it is an Instance
Attachwhat 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:

PolicyMeaningDefault
MaxIdleRetention ceiling. A return past it is disposed instead of pooled.unbounded
GrowthChunkHow 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.

ParameterTypeMeaning
templateTThe object to clone. Must have a Clone method.
preallocatenumber?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.

ParameterTypeMeaning
namestringDiagnostics label for the pool.
factory() -> TProduces a brand-new T. Must not yield.
preallocatenumber?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:

FieldRejected whenWhy it cannot wait
confignot a table
Factorynot a functionThe one required field.
Namepresent and not a stringObjectPool.snapshot sorts on it, and one number-named pool makes that comparison throw for every other pool in the VM too.
MaxIdlepresent and not a non-negative numberA negative ceiling disposes every return; NaN compares false against every count, silently removing the ceiling altogether. math.huge is legal and means unbounded.
GrowthChunkpresent and not a finite numberAn infinite chunk is a Get that never returns. Floored and clamped to at least 1.
Preallocatepresent and not a finite numberLikewise, at construction. Clamped to 0 from below.
Sanitize, Park, Dispose, Attachpresent and not a functionThese 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.

FieldTypeMeaning
NamestringDiagnostics label. Defaults to "unnamed".
SerialnumberMonotonic id, unique per VM.
Factory, Sanitize, Park, Dispose, AttachhooksAs configured, with defaults resolved.
MaxIdlenumberRetention ceiling; math.huge when unbounded.
GrowthChunknumberFloored and clamped to at least 1.
InactiveList{ T }The free list.
InactiveCountnumberHow many entries of InactiveList are live.
ActiveList{ [T]: boolean }The weak-keyed lease ledger.
Leases, Created, Recycled, Disposed, RejectednumberLifetime counters; also on Stats().
WarnedOverflowbooleanWhether the MaxIdle warning has already fired.
DestroyedbooleanWhether Destroy has been called.

Performance notes

  • The lease path allocates nothing. Get on a non-empty pool is one array read, one nil write and two counter increments. There is no table.remove (which itself pays #t), no # anywhere on a hot path — InactiveCount is 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.
  • Return pays one pcall. That is deliberate: a Sanitize that throws must not leave the ledger corrupted. The guarded call is made against a module-level function, so it allocates no closure.
  • Stats() and LeasedCount() are O(n) in the number of leased items, and Stats() allocates a table. Read them on a timer, never per frame. IdleCount() is O(1).
  • Adopt is 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, Dispose or Attach that yields is a caller bug.
  • Pools are per-Luau-VM. A pool required inside a worker Actor is that Actor's own, not a shared one, and Get/Return on 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:

SpecAsserts
portability.luausrc/init.luau names no engine global, and VERSION matches both manifests.
package.luauThe published surface type-checks from a consumer's position, against every exported type.
documentation.luauThe 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

check_circle

Safe for commercial use

Automated license review — not legal advice.