Stages
Stages is gameplay resource streaming by zone occupancy — and that phrase has nothing to do with Roblox instance StreamingEnabled. See StreamingEnabled for what that engine feature actually controls (client-side instance presentation); Stages is a completely different concept that happens to share the word “streaming” in casual conversation. Stages decides when your resources — a dungeon’s mob spawners, a boss’s AI loop, an area’s ambient VFX, a replicated boss-health bar — come into existence and go out of it, based on whether any player is standing in a Zone. The defining decision: Load returns its own unload closure, and the unload doesn’t run the instant the zone empties — it runs after a linger grace period, so a player stepping out and back in doesn’t thrash the load/unload cycle.
Nothing here inspects a client’s streamed geometry, and nothing here decides what instances a client can see. A stage can be fully loaded (mobs spawned, AI ticking, a Replica bound) for players whose clients haven’t streamed in a single part of the arena yet. Conversely, unloading a stage doesn’t unstream anything — it just tears down the server-side resources the Load closure created. Keep the two concepts separate: StreamingEnabled is the engine deciding what geometry a client’s window onto the world contains; Stages is your game deciding what simulation exists for a zone, keyed off whether anyone actually needs it running.
Mental model
Section titled “Mental model”A stage binds resources to a zone name
Section titled “A stage binds resources to a zone name”Stages:define(name, definition) registers a stage keyed by a Zones name — not a zone handle, the name string, the same one you’d pass to zones:add. A definition is a plain table:
Stages:define("BossArena", { Zone = "Arena", -- a Zones name; Stages listens to its bus topics Load = function(stage) local Boss = Npcs:spawn("Dragon", LairCf) local Ambience = Audio:soundscape("LairDrone") return function() -- the unload Boss:destroy() Ambience:stop() end end, OnEnter = function(stage, player) Audio:playFor(player, "LairSting") end, OnLeave = function(stage, player) end, Replicas = { ArenaState }, -- replica handles bindZone while loaded Linger = 10,})Defining two stages against the same zone name is legal and common — self.ByZone[zoneName] is a list, and every stage registered against a zone gets its own _enter/_leave call on every Zone.Entered/Zone.Left. A boss AI stage and an ambient-audio stage can both watch "Arena" independently, each with its own Load, Linger, and occupant bookkeeping.
Load returns the unload — there is no separate Unload callback pair by default
Section titled “Load returns the unload — there is no separate Unload callback pair by default”The pattern is deliberate: Load runs once per occupancy episode and its return value is the cleanup closure, captured as stage.Cleanup. This is the same “the setter hands back the undo” shape as Effects’s OnApply returning its own removal logic — the code that knows what it allocated is the code best positioned to know how to free it, so pairing them in one function body means the unload can never drift out of sync with what Load actually created (no separately-maintained teardown list to forget an entry in).
There is also a Definition.Unload field, but it’s a distinct, optional hook that runs after the Load-returned cleanup, every time the stage unloads — for teardown that doesn’t depend on anything Load allocated (e.g. a stage-level log line). Most stages don’t need it; the Load-returns-cleanup pair covers the normal case.
First-in loads, last-out unloads after Linger
Section titled “First-in loads, last-out unloads after Linger”Occupancy transitions, not raw enter/leave, drive Load/unload:
- The first occupant to enter a stage’s zone triggers
_load— every following occupant while the stage stays loaded triggers nothing at the stage level. - The last occupant to leave does not unload immediately.
_leavesetsstage.EmptySince = self.Clock()whenstage.Countdrops to0, and a periodic sweep (step, scheduled every1second on the Scheduler atPriority.Low) unloads the stage onceNow - EmptySince >= Linger. - Default
Lingeris10seconds (DefaultLinger = 10in source, matching the changelog).
Re-entry during the linger window cancels the pending unload. Confirmed directly from source and the spec: _enter unconditionally sets stage.EmptySince = nil before anything else. The sweep’s unload condition is Stage.Loaded and Stage.EmptySince — with EmptySince back to nil, the sweep has nothing to compare and skips the stage entirely. There’s no separate “cancel” bookkeeping; a re-entry just wipes the timestamp the sweep was measuring against. Stages.spec.luau’s “re-entering during the linger cancels the unload” test walks exactly this: a player leaves, 8 of a 10-second linger elapses, the player re-enters, then 100 more seconds pass and the sweep still reports zero unloads — the linger died the moment EmptySince was cleared, and no fresh countdown was needed since the stage was never empty again after that.
This is why the linger exists at all: without it, a player pacing back and forth across a zone boundary would spawn and despawn the boss AI, the ambience, and the bound replica on every crossing. The grace period turns “instantly empty” into “empty for a while,” and a step back in during that window is treated as if the stage was never vacated.
OnEnter/OnLeave fire per occupant — Load/unload fire once per episode
Section titled “OnEnter/OnLeave fire per occupant — Load/unload fire once per episode”This is the sharpest distinction on the page, and it’s easy to blur:
| Hook | Fires on | Frequency |
|---|---|---|
Load (and its returned cleanup) |
The zone’s occupant count crossing 0 → 1 (load) or 1 → 0 past the linger (unload) |
Once per occupancy episode |
OnEnter |
Every individual player entering the zone while the stage is registered | Once per player per entry — the first occupant and the fifth |
OnLeave |
Every individual player leaving the zone | Once per player per exit |
Concretely: five players wandering into "Arena" one at a time calls OnEnter five times, but Load runs exactly once — for the first arrival. OnEnter for the first player runs after _load has already executed (_enter calls self:_load(stage) before firing OnEnter), so OnEnter code can always assume the stage’s resources already exist, first occupant included. Symmetrically, OnLeave fires for the departing player before the stage’s occupant count is checked against zero — the last-out OnLeave still runs as usual, and only after it does the emptiness get recorded for the linger sweep.
Occupancy tracking: entirely delegated to Zones’ own bus topics
Section titled “Occupancy tracking: entirely delegated to Zones’ own bus topics”Stages does not run its own spatial queries. attach subscribes to the global Bus topics Zones already publishes — Zone.Entered and Zone.Left — and looks up every stage registered against the matching zone name via self.ByZone[zoneName]. Stages keeps its own occupant count per stage (stage.Occupants, a set, plus stage.Count), but that count is purely a tally driven by those two bus events — Stages never queries zones:playersIn itself except once, at definition time (see seeding below). A player disconnecting inside a zone counts down automatically for the same reason Zones documents: Zones fires Zone.Left for every zone a departing player was inside on Kernel.SessionEnd, and Stages’ _leave handler runs off that same event like any other leave.
Seeding: a stage defined after players are already standing in the zone loads immediately
Section titled “Seeding: a stage defined after players are already standing in the zone loads immediately”Stages.attach(kernel, { Zones = ... }) accepts a Zones instance. If given, Stages:define — right after registering the stage — calls self.Zones:playersIn(definition.Zone) and runs self:_enter(Stage, Inside) for every player already standing in the zone at the moment the stage is defined. This is confirmed directly from source (the -- Players already standing inside when the stage is defined count block in define) and from the spec: a FakeZones:playersIn("Arena") returning a player who’s “already standing inside” causes Kit:loaded("BossArena") to be true immediately after define returns, with no Zone.Entered event published at all.
Without this seed, a stage defined mid-session — after some players had already wandered into its zone before the stage even existed — would wait forever for an Entered event that will never come for players already inside; they’d have to leave and re-enter to trigger the stage. Seeding makes stage definition order-independent from player movement: define a stage any time, and it correctly recognizes whoever’s already there.
Replicas: bound to the zone automatically while the stage is loaded
Section titled “Replicas: bound to the zone automatically while the stage is loaded”A stage definition’s Replicas field is a list of already-created Replica handles. On _load, Stages calls Replica:bindZone(self.Zones, Definition.Zone) for each one and keeps the returned unbind function; on _unload, every unbind runs before the Load-returned cleanup fires. The effect: a replica listed under a stage’s Replicas only ever has Interest scoped to players standing in that zone, and only for as long as the stage is loaded — outside the load window there’s no bind, no subscribers, no bytes.
Because bindZone itself subscribes to Zone.Entered/Zone.Left for instant flips (see Replica), a player entering an already-loaded stage’s zone gets the replica’s snapshot immediately rather than waiting for a scan tick — Stages doesn’t need to do anything extra for that; it’s a property of bindZone that Stages simply invokes at the right time.
Failures are isolated per closure — one broken stage never stalls the sweep
Section titled “Failures are isolated per closure — one broken stage never stalls the sweep”Every user-supplied closure — Load, the closure Load returns, Unload, OnEnter, OnLeave, and even each Replica:bindZone call — runs through a shared guarded() helper that wraps the call in pcall. A throw anywhere in that chain is caught, logged with warn( [Stages] “{stageName}” {what} failed: {Result} ), and swallowed — it does not propagate up into _load/_leave/step, which keep processing every other stage and every other zone’s occupants exactly as if the failing closure had succeeded silently. Confirmed by the “throwing closures warn without breaking the lifecycle” spec: a stage whose Load and OnEnter both throw still ends up Loaded == true (the state transition is unconditional; only the closure’s result is discarded), and a second, healthy stage sharing the same zone loads and unloads normally alongside it.
A dungeon stage that spawns mob AI on first entry, despawns it on last-out, and binds a boss-health replica to the zone while anyone’s inside:
-- src/Server/Bootstrap.luaulocal ServerScriptService = game:GetService("ServerScriptService")local Root = ServerScriptService.ChloeKernelServer
local Zones = require(Root.Zones)local Stages = require(Root.Stages)local ReplicaService = require(Root.Replica)local Npcs = require(Root.NpcKit) -- your NPC spawner of choice
return function(kernel) local Regions = Zones.attach(kernel) Regions:add("Dungeon", workspace.DungeonVolume)
local Replicas = ReplicaService.new(kernel) local DungeonState = Replicas:create("DungeonState", { Schema = { BossHealth = "NumberU32", Phase = "String" }, Data = { BossHealth = 5000, Phase = "Idle" }, })
-- Zones passed here: seeds standing occupants at define time, and -- unlocks Replicas (bindZone needs a live Zones instance). local Stage = Stages.attach(kernel, { Zones = Regions })
Stage:define("Dungeon", { Zone = "Dungeon", Linger = 10, Load = function(stage) local Boss = Npcs:spawn("DungeonBoss", workspace.DungeonVolume.CFrame) DungeonState:patch({ BossHealth = 5000, Phase = "Awake" })
return function() -- the unload Boss:destroy() DungeonState:patch({ Phase = "Idle" }) end end, OnEnter = function(stage, player) kernel.Bus:publish("Dungeon.Announce", player, "The air grows cold.") end, OnLeave = function(stage, player) kernel.Bus:publish("Dungeon.Announce", player, "You step back into the light.") end, Replicas = { DungeonState }, })endA client bridging Stage.Loaded for ambient cosmetics — fade in dungeon audio the moment the stage actually loads, not on a guessed timer:
-- Server Bootstrap: whitelist the topic for broadcastBusBridge.attach(kernel, { ServerTopics = { "Stage.Loaded", "Stage.Unloaded" },})-- Client Bootstrapkernel.Bus:subscribe("Stage.Loaded", function(_topic, stageName) if stageName == "Dungeon" then Audio:fadeIn("DungeonDrone", 2) endend)kernel.Bus:subscribe("Stage.Unloaded", function(_topic, stageName) if stageName == "Dungeon" then Audio:fadeOut("DungeonDrone", 2) endend)See BusBridge for the full ServerTopics/ClientTopics shape — Stage.* bus events carry no player instances or secret state, so broadcasting them to every client is safe by the same rule BusBridge documents for public topics.
API reference
Section titled “API reference”Stages.attach(kernel, options?):
| Option | Default | Description |
|---|---|---|
Zones |
nil |
A live Zones instance. Seeds standing occupants for stages defined after players are already inside; required if any stage uses Replicas |
Clock |
os.clock |
Injectable clock (spec seam) — the sweep measures Linger against this |
SkipLoop |
false |
Do not schedule the sweep on the Scheduler; drive :step() yourself |
Stage definition (Stages:define(name, definition)):
| Field | Type | Description |
|---|---|---|
Zone |
string |
The Zones name whose occupancy drives this stage |
Load |
(stage) -> (() -> ())? |
Runs once on the first occupant. Its return value is the unload closure, called at teardown |
Unload |
(stage) -> () |
Optional, runs after Load’s returned cleanup at every unload |
OnEnter |
(stage, player) -> () |
Runs once per player, every entry, while the stage is registered |
OnLeave |
(stage, player) -> () |
Runs once per player, every exit |
Replicas |
{ Replica }? |
Replica handles bindZoned to Zone while loaded, unbound on unload. Requires Zones at attach |
Linger |
number? = 10 |
Seconds the stage stays loaded after its last occupant leaves before unloading |
Stage (attached instance) members:
| Member | Description |
|---|---|
Stages:define(name, definition) → stage |
Registers a stage. Errors on a duplicate name; errors if Replicas is set without Zones at attach |
Stages:step() |
Runs the linger sweep once — unloads any stage whose EmptySince has aged past its Linger. Called automatically every 1s unless SkipLoop |
Stages:occupants(name) → { player } |
Current occupants of a stage |
Stages:loaded(name) → boolean |
Whether the stage’s resources are currently loaded |
Stages:destroy() |
Cancels the sweep loop, disconnects the Zone.Entered/Zone.Left subscriptions, and force-unloads every defined stage |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Stage.Loaded |
stageName |
The stage’s Load ran (first occupant, or a seeded standing occupant at define time) |
Stage.Unloaded |
stageName |
The stage’s cleanup ran (linger expired, or destroy() forced it) |
Stage.Entered |
stageName, player |
Any individual player entered the stage’s zone — fires after Load has already run for a first arrival |
Stage.Left |
stageName, player |
Any individual player left the stage’s zone — fires before the emptiness check that arms the linger |
Consumed: Zone.Entered, Zone.Left — Stages holds no query logic of its own; every occupancy decision rides these two topics from Zones.