Skip to content

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.

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; acquire pops the most recently released instance (warm in cache), release pushes.
  • IdleSet — a set mirror of Idle, 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, and destroy() 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)
end
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.
  • ProjectilesProjectileClient keeps 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.
  • 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.

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:

  1. Gather + dedupe. String ids dedupe by value (an id in both a bank and your Assets list preloads once). Instances pass through untouched — PreloadAsync resolves their asset references itself.
  2. Batch. Slices of BatchSize (default 16) go to the preloader sequentially. Batching bounds how much one PreloadAsync call bites off, and a per-batch pcall isolates failures: a batch that throws is warned ([Preload] batch failed: ...) and skipped, and the pass continues with the next batch.
  3. Narrate. Every per-asset callback publishes Preload.Progress with running counts. Assets that fetch with a non-Success status are collected into Failed — a missing asset id never aborts the warm-up.
  4. Finish. Preload.Done publishes, the handle flips Done = true, and every thread blocked in await() 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.luau
local 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 renders
end

The loading screen is also the right moment to run DeviceBench: Preload occupies the network while the bench uses the idle CPU.

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.
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.
  • Total is fixed when run() 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.