{"id":"herilfiu/luau-object-pool","name":"luau-object-pool","scope":"herilfiu","platform":"roblox","description":"A reusable object pool util made for Roblox games.","version":"0.1.0","latest":"0.1.0","versions":["0.1.0"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"ba7b02a67e9395d207d93e285868d92d26a3c58221db49db4c269d98bdce6a2b","likes":0,"downloads":0,"install":"forest install herilfiu/luau-object-pool","url":"https://forest.dev/p/roblox/herilfiu/luau-object-pool","files":"https://api.forest.dev/ai/package/roblox/herilfiu/luau-object-pool/files","readme":"# luau-object-pool\n\n[![CI](https://github.com/HeriLFIU/luau-object-pool/actions/workflows/ci.yml/badge.svg)](https://github.com/HeriLFIU/luau-object-pool/actions/workflows/ci.yml)\n[![Wally Package](https://img.shields.io/endpoint?url=https%3A%2F%2Ftwirly.dev%2Fwally%2Fv1%2Fherilfiu%2Fluau-object-pool)](https://wally.run/package/herilfiu/luau-object-pool)\n[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)\n\nA reusable object pool for Roblox games — and for anything else that runs Luau.\n\nA pool is an **allocator with its policy lifted out of it**: a free list, a ledger of what is\nchecked out, and a set of replaceable hooks naming everything the allocator itself must not know.\nReuse `Part`s, `Model`s, UI frames, or plain Luau tables through one generic, `--!strict` API.\n\n```luau\nlocal pool = ObjectPool.new(ReplicatedStorage.Assets.Bullet, 256)\n\nlocal bullet = pool:Get(workspace.Projectiles) -- leases; allocates nothing on a hit\npool:Return(bullet)                            -- sanitizes, unparents, requeues\n```\n\n---\n\n## Features\n\n- **Generic over `T`.** `BasePart`, `Model`, `Frame`, `Sound`, or a plain table. The Instance\n  behaviours are *defaults* reached through a runtime `typeof` test, not casts asserted over `T`.\n- **Zero engine dependencies.** The module names no `game`, no `Instance.new`, no `task`, no `warn`.\n  Roblox reaches it through values you pass in, so the whole test suite runs headless under Lune.\n- **No allocation on the lease path.** A `Get` that hits the free list is one array read, one nil\n  write and two counter increments. No `table.remove`, no `#`, no closures, no yields.\n- **Double returns are rejected, not tolerated** — the single nastiest pooling bug, caught at source.\n- **Explicit lifecycle.** `MaxIdle` caps retention, `Trim` gives memory back, `Destroy` is terminal.\n- **Fails fast, at the line that caused it.** Every hook and policy number is validated where the\n  pool is built, not where it is later used, and each constructor names its own parameters — so a bad\n  argument points at your call rather than at a frame inside the library.\n- **Strictly typed.** `--!strict` from line 1, with every public type exported for Luau LSP.\n- **Dual ecosystem.** Ships to both Wally and pesde from one source tree.\n\n---\n\n## Contents\n\n- [Installation](#installation)\n- [Quick start](#quick-start)\n- [How it works](#how-it-works)\n- [Feature guide](#feature-guide)\n- [The master example: a projectile manager](#the-master-example-a-projectile-manager)\n- [API reference](#api-reference)\n- [Performance notes](#performance-notes)\n- [Gotchas](#gotchas)\n- [Development](#development)\n\n---\n\n## Installation\n\n### Wally\n\n```toml\n[dependencies]\nObjectPool = \"herilfiu/luau-object-pool@^0.1.0\"\n```\n\n```bash\nwally install\n```\n\n### pesde\n\n```bash\npesde add herilfiu/luau_object_pool\npesde install\n```\n\n### Requiring it\n\n```luau\n--!strict\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)\n```\n\n---\n\n## Quick start\n\n```luau\n--!strict\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)\n\n-- Route diagnostics to the engine's warning channel. Once, at startup.\nObjectPool.setReporter(warn)\n\n-- A pool of 64 clones of an authored template.\nlocal pool = ObjectPool.new(ReplicatedStorage.Assets.Impact, 64, function(part: BasePart)\n\tpart.Transparency = 0\n\tpart.Size = Vector3.one\nend)\n\n-- Lease one and place it. `Get` parents the instance at the target for you.\nlocal impact = pool:Get(workspace.Effects)\nimpact.CFrame = CFrame.new(0, 10, 0)\n\n-- Hand it back. Sanitize runs, then the part is unparented and requeued.\npool:Return(impact)\n\nprint(pool:IdleCount()) --> 64\n```\n\n---\n\n## How it works\n\nA pool holds two structures and five hooks.\n\n| Structure | What it is |\n| --- | --- |\n| `InactiveList` / `InactiveCount` | The free list. LIFO, so the newest return is the next lease and the working set stays hot. |\n| `ActiveList` | The lease ledger. **Weak-keyed**, so an abandoned lease cannot pin a dead object for the session. |\n\n| Hook | Answers | Default |\n| --- | --- | --- |\n| `Factory` | how is one made? | *required* |\n| `Sanitize` | what dirty state must be reset before reuse? | nothing |\n| `Park` | where does an idle item live? | `Parent = nil` if it is an Instance |\n| `Dispose` | how is one destroyed for good? | `:Destroy()` if it is an Instance |\n| `Attach` | what does `Get`'s optional target mean? | `Parent = target` if it is an Instance |\n\n…plus two numbers of policy, which are yours to state and the pool's to enforce:\n\n| Policy | Meaning | Default |\n| --- | --- | --- |\n| `MaxIdle` | Retention ceiling. A return past it is disposed instead of pooled. | unbounded |\n| `GrowthChunk` | How many to build when a `Get` finds the free list empty. | `1` |\n\nThe *value* of a policy is application-specific, so it is supplied per pool. The *enforcement*\nbelongs in one place — otherwise it reappears in every caller, differently each time.\n\n---\n\n## Feature guide\n\n### Creating a pool\n\nThree constructors, all of which funnel into one construction path.\n\n**From a template** — the common case for authored assets:\n\n```luau\nlocal pool = ObjectPool.new(ReplicatedStorage.Assets.Bullet, 128, function(part: BasePart)\n\tpart.AssemblyLinearVelocity = Vector3.zero\nend)\n```\n\nThe template is never mutated, never parented and never handed out. The pool takes its diagnostics\nname from the template's `Name`.\n\n**From a factory** — for objects with no authored template, and the place an engine dependency\nbelongs:\n\n```luau\nlocal attachments = ObjectPool.fromFactory(\"Attachment\", function(): Attachment\n\treturn Instance.new(\"Attachment\")\nend, 32)\n\nlocal trailPoints = ObjectPool.fromFactory(\"TrailPoint\", function()\n\treturn { Position = Vector3.zero, Age = 0 }\nend)\n```\n\n**From a full config** — every hook and both policy numbers:\n\n```luau\nlocal tracers = ObjectPool.fromConfig({\n\tName = \"Tracer\",\n\tFactory = function(): Part\n\t\tlocal part = Instance.new(\"Part\")\n\t\tpart.Anchored = true\n\t\tpart.CanCollide = false\n\t\tpart.CanQuery = false\n\t\tpart.Material = Enum.Material.Neon\n\t\treturn part\n\tend,\n\tSanitize = function(part: Part)\n\t\tpart.CFrame = CFrame.identity\n\t\tpart.Transparency = 0\n\tend,\n\tMaxIdle = 256,\n\tGrowthChunk = 16,\n\tPreallocate = 64,\n})\n```\n\n### Renting an object\n\n`Get` always answers an item, growing the pool by `GrowthChunk` if the free list is empty.\nThe optional argument is handed to the `Attach` hook — which, by default, parents the instance.\n\n```luau\nlocal part = pool:Get(workspace.Effects) -- parented for you\nlocal orphan = pool:Get()                -- you place it yourself\n```\n\n`TryGet` never allocates. It answers `nil` on an empty pool, which is the seam for a caller whose\n\"make a new one\" path is not the pool's `Factory`:\n\n```luau\nlocal rig = pool:TryGet()\nif rig == nil then\n\trig = templateRegistry:Build(kind)\n\tpool:Adopt(rig) -- now a later Return is a legal return\nend\n```\n\n### Returning an object\n\n```luau\npool:Return(part) --> true when the part is now idle in the pool\n```\n\n`Return` runs `Sanitize`, then `Park`, then requeues — unless the pool already holds `MaxIdle`\nitems, in which case the item is disposed instead. It answers `false` in every case where the item\ndid **not** end up idle in the pool.\n\nIf you destroyed the object yourself, hand it back with `Discard`, never `Return`:\n\n```luau\nif part:IsDescendantOf(workspace) then\n\tpool:Return(part)\nelse\n\tpool:Discard(part) -- disposes it and drops it from the ledger\nend\n```\n\n### Pre-allocating and trimming\n\n```luau\npool:Expand(200) --> 200 ; build ahead of a burst, off the frame the player is looking at\npool:Trim(32)    -->  n  ; dispose idle items down to a warm floor\npool:Trim(0)     -->  n  ; give it all back at round teardown\n```\n\n`Expand` is deliberately **not** clamped to `MaxIdle`: an explicit pre-warm is a statement of intent.\n`Trim` never touches leased items.\n\n### Adopting foreign objects\n\n```luau\nlocal rig = somethingElseBuiltThis()\npool:Adopt(rig)  --> true ; the pool now considers it leased\npool:Return(rig) --> true ; …so this is a legal return, not a rejected one\n```\n\n`Adopt` refuses an item that is already leased or already idle — either would be the double-lease\nthe pool exists to prevent.\n\n### Non-Instance pools\n\n`T` is genuinely unconstrained. No Instance semantics are involved unless `typeof(item)` actually\nanswers `\"Instance\"`:\n\n```luau\ntype Particle = { Position: Vector3, Velocity: Vector3, Life: number }\n\nlocal particles = ObjectPool.fromConfig({\n\tName = \"Particle\",\n\tFactory = function(): Particle\n\t\treturn { Position = Vector3.zero, Velocity = Vector3.zero, Life = 0 }\n\tend,\n\tSanitize = function(particle: Particle)\n\t\tparticle.Position = Vector3.zero\n\t\tparticle.Velocity = Vector3.zero\n\t\tparticle.Life = 0\n\tend,\n\tPreallocate = 512,\n})\n```\n\n### Teardown\n\n```luau\npool:Destroy()\n```\n\nDisposes everything the pool owns — idle *and* still-leased — and retires it. This is terminal:\na later `Get`, `Expand` or `Adopt` raises. `Return` and `Discard` keep working, so an in-flight\nlease arriving after teardown is disposed rather than leaked. Calling it twice is a no-op.\n\n### Diagnostics\n\n```luau\nObjectPool.setReporter(warn) -- once, at startup: route messages to the engine\n\nlocal stats = pool:Stats()\nprint(`{stats.Idle} idle, {stats.Leased} out, {stats.Created} ever built`)\n\nfor _, row in ObjectPool.snapshot() do\n\tprint(`{row.Name}#{row.Serial}: {row.Stats.Idle} idle / {row.Stats.Leased} out`)\nend\n```\n\n`Created` climbing while `Leases` climbs with it means nothing is being reused. `Rejected` above\nzero is always a caller bug.\n\nThe sink's type is exported as `ObjectPool.Reporter`, for a reporter you keep in a variable:\n\n```luau\nlocal collect: ObjectPool.Reporter = function(message: string)\n\ttable.insert(log, message)\nend\n\nObjectPool.setReporter(collect)\nObjectPool.setReporter(nil) -- back to the default, `print`\n```\n\n---\n\n## The master example: a projectile manager\n\nA complete server-side weapon system. It shows the whole lifecycle in one place: two pools (one for\nthe visible parts, one for the state records), a warm pre-allocation at start-up, high-frequency\nrenting and returning inside `RunService.Heartbeat`, cleanup when an owner leaves mid-flight, a\nretention floor at round teardown, and a terminal `Destroy` on shutdown.\n\n```luau\n--!strict\n-- ServerScriptService/ProjectileService.luau\n\nlocal Players = game:GetService(\"Players\")\nlocal ReplicatedStorage = game:GetService(\"ReplicatedStorage\")\nlocal RunService = game:GetService(\"RunService\")\nlocal Workspace = game:GetService(\"Workspace\")\n\nlocal ObjectPool = require(ReplicatedStorage.Packages.ObjectPool)\n\n--- One projectile in flight. `Part` is optional because `Sanitize` nils it on return: a pooled\n--- record that still points at a BasePart keeps that part alive, which is exactly the leak a pool\n--- is supposed to prevent.\ntype Projectile = {\n\tPart: BasePart?,\n\tOrigin: Vector3,\n\tVelocity: Vector3,\n\tElapsed: number,\n\tLifetime: number,\n\tDamage: number,\n\tOwner: Player?,\n}\n\nlocal MAX_LIFETIME = 3\nlocal GRAVITY = Vector3.new(0, -60, 0)\nlocal WARM_COUNT = 256\nlocal IDLE_CEILING = 512\n\nlocal ProjectileService = {}\n\nObjectPool.setReporter(warn)\n\nlocal container = Instance.new(\"Folder\")\ncontainer.Name = \"Projectiles\"\ncontainer.Parent = Workspace\n\n-- Allocated once and reused for every cast: a fresh RaycastParams per frame is exactly the garbage\n-- a pool exists to stop producing.\nlocal castParams = RaycastParams.new()\ncastParams.FilterType = Enum.RaycastFilterType.Exclude\ncastParams.FilterDescendantsInstances = { container }\n\n--------------------------------------------------------------------------------\n-- Pools\n--------------------------------------------------------------------------------\n\nlocal partPool = ObjectPool.fromConfig({\n\tName = \"ProjectilePart\",\n\tFactory = function(): BasePart\n\t\tlocal part = Instance.new(\"Part\")\n\t\tpart.Size = Vector3.new(0.2, 0.2, 2)\n\t\tpart.Material = Enum.Material.Neon\n\t\tpart.Color = Color3.fromRGB(255, 200, 80)\n\t\tpart.Anchored = true\n\t\tpart.CanCollide = false\n\t\tpart.CanQuery = false\n\t\tpart.CanTouch = false\n\t\tpart.CastShadow = false\n\t\treturn part\n\tend,\n\tSanitize = function(part: BasePart)\n\t\tpart.CFrame = CFrame.identity\n\t\tpart.Transparency = 0\n\tend,\n\tMaxIdle = IDLE_CEILING,\n\tGrowthChunk = 32,\n\tPreallocate = WARM_COUNT,\n})\n\nlocal recordPool = ObjectPool.fromConfig({\n\tName = \"ProjectileRecord\",\n\tFactory = function(): Projectile\n\t\treturn {\n\t\t\tPart = nil,\n\t\t\tOrigin = Vector3.zero,\n\t\t\tVelocity = Vector3.zero,\n\t\t\tElapsed = 0,\n\t\t\tLifetime = MAX_LIFETIME,\n\t\t\tDamage = 0,\n\t\t\tOwner = nil,\n\t\t}\n\tend,\n\tSanitize = function(record: Projectile)\n\t\t-- Drop every reference the record holds. Numbers can stay dirty; references cannot.\n\t\trecord.Part = nil\n\t\trecord.Owner = nil\n\tend,\n\tMaxIdle = IDLE_CEILING,\n\tGrowthChunk = 32,\n\tPreallocate = WARM_COUNT,\n})\n\n--- Live projectiles, as a dense array with swap-remove, so retiring one is O(1) and the array never\n--- shrinks its allocation.\nlocal live: { Projectile } = {}\nlocal liveCount = 0\n\n--------------------------------------------------------------------------------\n-- Firing and retiring\n--------------------------------------------------------------------------------\n\n--- Leases a part and a record and puts a projectile in the air.\nfunction ProjectileService.Fire(owner: Player, origin: Vector3, direction: Vector3, speed: number, damage: number)\n\tlocal part = partPool:Get(container)\n\tlocal record = recordPool:Get()\n\n\trecord.Part = part\n\trecord.Origin = origin\n\trecord.Velocity = direction.Unit * speed\n\trecord.Elapsed = 0\n\trecord.Lifetime = MAX_LIFETIME\n\trecord.Damage = damage\n\trecord.Owner = owner\n\n\tpart.CFrame = CFrame.lookAlong(origin, direction)\n\n\tliveCount += 1\n\tlive[liveCount] = record\nend\n\n--- Hands both leases back and swap-removes the record from the live array.\nlocal function retire(index: number)\n\tlocal record = live[index]\n\tlocal part = record.Part\n\n\tif part ~= nil then\n\t\tif part:IsDescendantOf(container) then\n\t\t\tpartPool:Return(part)\n\t\telse\n\t\t\t-- Something else destroyed or reparented it. `Discard` disposes it and drops it from the\n\t\t\t-- ledger, so it never re-enters the free list in an unknown state.\n\t\t\tpartPool:Discard(part)\n\t\tend\n\tend\n\n\trecordPool:Return(record)\n\n\tlive[index] = live[liveCount]\n\tlive[liveCount] = nil\n\tliveCount -= 1\nend\n```\n\n```luau\n--------------------------------------------------------------------------------\n-- The frame loop\n--------------------------------------------------------------------------------\n\nRunService.Heartbeat:Connect(function(deltaTime: number)\n\t-- Backwards, because `retire` swap-removes: walking forwards would skip the record that was\n\t-- moved into the slot just vacated.\n\tfor index = liveCount, 1, -1 do\n\t\tlocal record = live[index]\n\t\tlocal part = record.Part\n\n\t\tif part == nil then\n\t\t\tretire(index)\n\t\t\tcontinue\n\t\tend\n\n\t\trecord.Elapsed += deltaTime\n\t\trecord.Velocity += GRAVITY * deltaTime\n\n\t\tlocal from = part.Position\n\t\tlocal step = record.Velocity * deltaTime\n\t\tlocal hit = Workspace:Raycast(from, step, castParams)\n\n\t\tif hit ~= nil then\n\t\t\tProjectileService.OnHit(record, hit)\n\t\t\tretire(index)\n\t\telseif record.Elapsed >= record.Lifetime then\n\t\t\tretire(index)\n\t\telse\n\t\t\tpart.CFrame = CFrame.lookAlong(from + step, record.Velocity)\n\t\tend\n\tend\nend)\n\n--- Damage resolution lives here so the loop above stays about motion.\nfunction ProjectileService.OnHit(record: Projectile, hit: RaycastResult)\n\tlocal character = hit.Instance:FindFirstAncestorOfClass(\"Model\")\n\tlocal humanoid = character and character:FindFirstChildOfClass(\"Humanoid\")\n\n\tif humanoid ~= nil and humanoid.Health > 0 then\n\t\thumanoid:TakeDamage(record.Damage)\n\tend\nend\n\n--------------------------------------------------------------------------------\n-- Cleanup\n--------------------------------------------------------------------------------\n\n-- An owner leaving mid-flight is the classic lingering-reference bug: the record would ","readmeTruncated":true,"readmeFull":"https://api.forest.dev/v1/package/herilfiu/roblox/luau-object-pool/0.1.0/readme"}