Pool & Preload
Two modules about paying instance costs once instead of constantly. Pool reuses Instances for things you spawn all the time — bullets, VFX, damage numbers — turning Instance.new/Destroy churn into table operations. Preload warms every asset your game registered through ContentProvider:PreloadAsync in batches, narrating progress on the bus so a loading screen can render it.
Mental model
Section titled “Mental model”Instance.new + Destroy churn fragments memory and stutters frames — every projectile tracer created and destroyed is allocation the GC must sweep (GcWatch shows this as churn: stable heap, busy cycles). A pool keeps retired instances in an idle list and vends them back out on the next acquire.
Internally a pool is three structures:
Idle— an array used as a stack;acquirepops the most recently released instance (warm in cache),releasepushes.IdleSet— a set mirror ofIdle, so a double release is an O(1) check instead of a scan.LiveSet+LiveCount— every instance currently checked out. Releasing something the pool never vended is an error, anddestroy()can reach checked-out instances too.
There is no cap and no shrinking: an empty idle list means acquire calls your Create factory, so the pool grows to your peak concurrent demand and holds there. That is the point — peak demand is what you would have allocated anyway, and holding it means never allocating it again.
Pool manages lifetime only. It never resets properties. release does exactly one thing to the instance — sets Parent = nil (which stops rendering, physics, and touch events) — and acquire hands it back exactly as it was released. Resetting visual state (position, transparency, particle emitters, attributes) is the acquirer’s job, because only the caller knows which properties its effect dirties.
-- src/Server/Bootstrap.luau (works identically on the client)local Pool = require(game:GetService("ReplicatedStorage").ChloeKernel.Pool)
return function(kernel) local Casings = Pool.new({ Create = function() local Casing = Instance.new("Part") Casing.Size = Vector3.new(0.2, 0.2, 0.5) Casing.Material = Enum.Material.Metal Casing.CanCollide = false Casing.CanQuery = false return Casing end, InitialSize = 16, -- prewarmed at boot, not mid-firefight })
kernel.Bus:subscribe("Weapon.Fired", function(_, muzzleCframe) local Casing = Casings:acquire() :: BasePart Casing.CFrame = muzzleCframe -- reset state on acquire, not on release Casing.Parent = workspace task.delay(2, function() Casings:release(Casing) end) end)endAPI reference
Section titled “API reference”| Member | Description |
|---|---|
Pool.new({ Create, InitialSize? }) → pool |
Create: () -> Instance is the factory. InitialSize (default 0) prewarms that many instances immediately, parented to nil. |
pool:acquire() → Instance |
Pops an idle instance, or calls Create when the idle list is empty. The instance is tracked as live. |
pool:release(instance) |
Unparents the instance and returns it to the idle list. Errors on a double release ("instance was already released to this pool") and on foreign instances ("instance was not acquired from this pool"). |
pool:idleCount() → number |
Instances waiting in the idle list. |
pool.LiveCount |
Instances currently checked out. idleCount() + LiveCount is the pool’s total footprint. |
pool:destroy() |
Destroys every idle and every live checked-out instance, then clears all tracking. |
Who uses pools internally
Section titled “Who uses pools internally”- Projectiles —
ProjectileClientkeeps one pool per visual definition (prewarmed 4 each) plus a shared tracer pool (prewarmed 8) for the minimal fallback visuals rendered when the full-visual cap is hit. Sixty projectiles a second costs zero instance churn. - Dissolve — one pool per voxel template (prewarmed 16); a dissolve effect’s hundreds of drifting voxels are all reused parts.
- Simulation — the load harness’s projectile visuals are pooled, so the stress test measures kernel cost, not instance churn.
Design notes
Section titled “Design notes”- Fixed-size effects with a natural lifetime (casings, hit sparks, damage numbers) suit
task.delay-driven release. Effects with variable lifetimes should release from the code that owns their end condition — the pool has no timeout of its own. - Pools are plain Luau with no kernel dependency; they work in any script, server or client, including inside processes.
Preload
Section titled “Preload”Mental model
Section titled “Mental model”The framework already knows most of the assets your game will touch: AudioKit banks and AnimKit banks each expose assetIds(). Preload.run gathers those, plus any extra content-id strings or whole Instances you pass, dedupes the ids, and feeds them to ContentProvider:PreloadAsync in batches on a spawned thread:
- Gather + dedupe. String ids dedupe by value (an id in both a bank and your
Assetslist preloads once). Instances pass through untouched —PreloadAsyncresolves their asset references itself. - Batch. Slices of
BatchSize(default 16) go to the preloader sequentially. Batching bounds how much onePreloadAsynccall bites off, and a per-batchpcallisolates failures: a batch that throws is warned ([Preload] batch failed: ...) and skipped, and the pass continues with the next batch. - Narrate. Every per-asset callback publishes
Preload.Progresswith running counts. Assets that fetch with a non-Successstatus are collected intoFailed— a missing asset id never aborts the warm-up. - Finish.
Preload.Donepublishes, the handle flipsDone = true, and every thread blocked inawait()resumes.
run() returns immediately with a live handle; await() is for flows that must block until warm (hold the loading screen, then start the round).
-- src/Client/Bootstrap.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local Preload = require(ReplicatedStorage.ChloeKernel.Preload)
return function(kernel) local Gui = Instance.new("ScreenGui") Gui.IgnoreGuiInset = true local Bar = Instance.new("Frame") Bar.Size = UDim2.fromScale(0, 0.02) Bar.Position = UDim2.fromScale(0, 0.98) Bar.BackgroundColor3 = Color3.fromRGB(145, 139, 255) Bar.Parent = Gui Gui.Parent = game:GetService("Players").LocalPlayer:WaitForChild("PlayerGui")
kernel.Bus:subscribe("Preload.Progress", function(_, loaded, total) Bar.Size = UDim2.fromScale(loaded / total, 0.02) end) kernel.Bus:subscribe("Preload.Done", function(_, loaded, total, failed) if #failed > 0 then warn(`{#failed} assets failed to preload`) end Gui:Destroy() end)
local Warmup = Preload.run(kernel, { -- Banks = { Audio, Anims }: pass your AudioKit/AnimKit instances (anything with :assetIds()) Assets = { "rbxassetid://9046898403", "rbxassetid://129423030", workspace }, -- ids and/or whole Instances }) Warmup:await() -- block this flow until warm; everything above already rendersendThe loading screen is also the right moment to run DeviceBench: Preload occupies the network while the bench uses the idle CPU.
API reference
Section titled “API reference”| Member | Description |
|---|---|
Preload.run(kernel, options?) → handle |
Publishes Preload.Started synchronously, then batches on a spawned thread. Needs kernel.Bus. |
Options (all optional):
| Field | Default | Description |
|---|---|---|
Assets |
{} |
Content-id strings and/or Instances. Strings dedupe; Instances pass through. |
Banks |
{} |
Anything with an :assetIds() → { string } method — AudioKit, AnimKit, or your own registry. |
BatchSize |
16 |
Assets per PreloadAsync call. |
Preloader |
ContentProvider:PreloadAsync |
Injectable (assets, callback(assetId, ok)) for specs. |
Handle:
| Field / method | Description |
|---|---|
handle.Done |
true once every batch has run. |
handle.Loaded |
Assets that have completed a fetch callback (success or failure). |
handle.Total |
Asset count fixed at gather time. |
handle.Failed |
Array of asset ids whose fetch status was not Success. |
handle:await() |
Yields the calling coroutine until done; returns immediately if already done. |
Bus topics
Section titled “Bus topics”| Topic | Payload | Fired |
|---|---|---|
Preload.Started |
(total) |
Once, synchronously inside run() — subscribe before calling run or you miss it. |
Preload.Progress |
(loaded, total, assetId, ok) |
Per asset, as each fetch resolves. |
Preload.Done |
(loaded, total, failed) |
Once, after the last batch. failed is the array of failed ids. |
Design notes
Section titled “Design notes”Totalis fixed whenrun()gathers — assets registered into a bank afterwards are not picked up. Register banks first, preload last.- Dedupe applies to string ids only. The same Instance passed twice is preloaded twice (harmless — the second fetch is a cache hit).
- Multiple
run()calls are independent passes with independent handles and bus narration; there is no global queue.