Skip to content

Preload

Warms registered assets through ContentProvider:PreloadAsync in concurrent batches and publishes progress on the Bus for a loading screen.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Preload
Since
v0.3.0
Source
Preload.luau

Preload gathers the assets a game registered, feeds them to ContentProvider:PreloadAsync in batches, and publishes progress on the Bus so a loading screen can draw it. Up to 4 batches run at once, so one slow asset holds back only its own batch. Every gathered entry counts exactly once: progress reaches the total and never passes it, and a failure never aborts the pass. AudioKit and AnimKit banks feed it through assetIds(). Pool is the other half of paying instance costs up front: it recycles live Instances instead of warming assets.

Preload.run(kernel, options?) does the first two steps before it returns the handle; the batches run on a spawned thread:

  1. It gathers Assets first, then each bank’s assetIds(). String ids dedupe by exact value, so an id in a bank and in Assets loads once. Instances pass through untouched and are never deduped. Total is the gathered count, fixed at this point.
  2. Preload.Started(total) publishes synchronously, before run() returns.
  3. The list splits into batches of BatchSize (default 16). Concurrency workers (default 4, at least 1) each take the next unclaimed batch until none remain. Each batch is one PreloadAsync call inside pcall. A batch that throws logs [Preload] batch failed: <error> through Logger and the pass continues.
  4. A listed id that reports Enum.AssetFetchStatus.TimedOut is held back instead of counted. After every first-pass batch has returned, the held ids run one more pass with the same batching. Whatever that attempt reports is final.
  5. Done becomes true, Preload.Done(loaded, total, failed) publishes, and every thread in await() resumes.

Each gathered entry adds exactly one to Loaded, and each count publishes Preload.Progress:

  • A listed string id counts when the callback reports it, successful or not, except a first-pass TimedOut, which waits for the retry. Failures go into Failed.
  • An Instance counts once, when its batch returns, with ok true if the batch did not throw. PreloadAsync reports every content id under the Instance through the callback. Those descendant ids do not count toward Loaded, but the ones that fail are added to Failed.
  • A listed id the callback never reported (its batch threw, or the engine reported it under a different spelling) asks ContentProvider:GetAssetFetchStatus for its current status. Success counts as loaded. TimedOut or Loading on the first pass joins the retry. Anything else, including a status lookup that errors, counts as failed.

So Loaded equals Total when Preload.Done fires. Before 0.9.0 batches ran one at a time, a thrown batch’s ids never counted (the bar stopped short), and an Instance counted once per descendant id (the bar overshot).

SurfaceAppearance and MaterialVariant texture packs do not preload; they stream in at runtime.

A controller starts the pass at boot, and the loading screen reads the handle when its ScreenGui binds:

src/Shared/Game/Config/StartupAssets.luau
--!strict
-- StartupAssets: content the first screen needs before it opens.
return {
"rbxassetid://9046898403",
"rbxassetid://129423030",
}
src/Client/Game/Controllers/PreloadController.luau
--!strict
-- PreloadController: warms the game's startup assets at boot.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Preload = require(ReplicatedStorage.ChloeKernel.Preload)
local StartupAssets = require(ReplicatedStorage.Game.Config.StartupAssets)
local Log = Logger.scope("PreloadController")
local PreloadController = {
Name = "PreloadController",
}
function PreloadController.init(self: any, kernel: any)
self.Kernel = kernel
end
function PreloadController.start(self: any)
-- Subscribe before run(): an empty or fully cached pass can finish before run() returns
self.Connection = self.Kernel.Bus:subscribe("Preload.Done", function(_topic, _loaded, _total, failed: { string })
if #failed > 0 then
Log.warn(`{#failed} assets failed to preload`, table.concat(failed, " "))
end
end)
self.Warmup = Preload.run(self.Kernel, {
Assets = StartupAssets, -- content id strings and Instances
-- Banks = { Audio, Anims }: AudioKit or AnimKit instances, or anything with :assetIds()
})
end
function PreloadController.stop(self: any)
if self.Connection then
self.Connection:disconnect()
end
end
return PreloadController
src/Client/Game/Screens/Loading.luau
--!strict
-- Loading screen: fills the bar from Preload progress and closes when warm.
local Loading = {}
function Loading.new(context: any, gui: ScreenGui)
local Kernel = context.Kernel
local Scope = Kernel:createScope()
local Warmup = context.Controllers("PreloadController").Warmup -- nil until that controller has started
local Bar = gui:FindFirstChild("Bar", true)
local function show(loaded: number, total: number)
if Bar and Bar:IsA("GuiObject") then
Bar.Size = UDim2.fromScale(loaded / math.max(total, 1), 1)
end
end
-- The gui binds when the character first spawns, which can be mid-pass or after it ended: read the handle first
local Finished = Warmup ~= nil and Warmup.Done
if Warmup then
show(Warmup.Loaded, Warmup.Total)
end
if not Finished then
Scope:add(Kernel.Bus:subscribe("Preload.Progress", function(_topic, loaded: number, total: number)
show(loaded, total)
end))
Scope:add(Kernel.Bus:subscribe("Preload.Done", function()
context.close("Loading")
end))
end
return {
Modal = false,
StartsOpen = not Finished,
open = function(_self)
gui.Enabled = true
end,
close = function(_self)
gui.Enabled = false
end,
destroy = function(_self)
Scope:destroy()
end,
}
end
return Loading

A flow that must block until everything is warm (hold the lobby until the round’s assets load) calls Warmup:await() from its own thread. The loading screen is also a good moment to run DeviceBench: Preload keeps the network busy while the bench uses idle CPU.

Member Description
Preload.run(kernel, options?) -> Handle Gathers, publishes Preload.Started, and runs the batches on a spawned thread. Needs kernel.Bus.
handle:await() Yields the calling thread until the pass is done. Returns at once if it already is.
handle.Done True once every batch, including the retry pass, has returned.
handle.Loaded Gathered entries counted so far. Reaches Total at Done.
handle.Total Gathered entry count, fixed when run() gathers.
handle.Failed Array of ids that failed: listed ids and descendant ids of listed Instances.

Options, all optional:

Field Default Description
Assets {} Content id strings and Instances. Strings dedupe; Instances pass through.
Banks {} Anything with :assetIds() -> { string }: AudioKit, AnimKit or your own registry.
BatchSize 16 Entries per PreloadAsync call.
Concurrency 4 Batches in flight at once. Values below 1 count as 1. Added in 0.9.0.
Preloader ContentProvider:PreloadAsync Spec seam: (assets, callback(assetId, ok, status?)).
FetchStatus ContentProvider:GetAssetFetchStatus Spec seam: (assetId) -> Enum.AssetFetchStatus?. The default is wrapped in pcall and returns nil on error.
Topic Payload Fired
Preload.Started (total) Once, synchronously inside run(). Subscribe before calling run.
Preload.Progress (loaded, total, assetId, ok, status?) Once per gathered entry as it counts. status is the Enum.AssetFetchStatus when there is one; an Instance reports its GetFullName() as assetId and no status.
Preload.Done (loaded, total, failed) Once, after the last batch. failed is the handle’s Failed array.
  • Total is fixed when run() gathers. Assets registered into a bank afterwards are not picked up, so register banks first and preload last.
  • Preload.Done can publish before run() returns when there is nothing to load or every batch completes without yielding. Subscribe first, or read handle.Done.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.