Stages
Loads gameplay resources when the first player enters a zone and unloads them a linger after the last one leaves, with per-occupant hooks and zone-scoped Replicas.
- Realm
- Server
- Require
ServerScriptService.ChloeKernelServer .Stages - Since
- v0.7.2
- Source
- Stages.luau
Stages streams gameplay resources by zone occupancy. It has nothing to do with Roblox instance streaming (StreamingEnabled), which decides what geometry a client holds. Stages decides when your server-side resources exist: a dungeon’s spawners, a boss’s AI, a replicated boss health bar. The first player into a Zones region loads them and the last one out unloads them. The defining decision: Load returns its own unload closure, and the unload runs a linger after the zone empties, so a player stepping out and back in does not thrash the cycle.
The two concepts stay separate. A stage can be loaded (boss spawned, AI running, Replica bound) for players whose clients have not streamed in a single part of the arena, and unloading a stage streams nothing out; it tears down what Load created.
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 against a Zones name, the same string you pass to zones:add:
Stage:define("BossArena", { Zone = "Arena", -- Stages listens to this zone's Bus topics Load = function(stage) local Boss = Npcs:spawn("Dragon", LairCFrame) return function() -- the unload Boss:destroy() end end, OnEnter = function(stage, player) Audio:playFor(player, "LairSting") -- Audio = AudioKit.server(kernel) end, Replicas = { ArenaState }, -- bound to the zone while loaded Linger = 10,})Several stages can watch one zone: each has its own Load, Linger and occupants, and each sees every Zone.Entered and Zone.Left for that zone. A boss stage and an ambience stage can both watch "Arena". Stage names are one flat table, though: defining a second stage with an existing name errors (stage "X" is already defined).
Load returns the unload
Section titled “Load returns the unload”Load runs once per occupancy episode, and its return value is the cleanup, kept on the stage. The code that knows what it allocated also knows how to free it, and keeping both in one function body means the unload cannot drift from what Load created.
Definition.Unload is a separate optional hook that runs after the returned cleanup at every unload, for teardown that does not depend on what Load allocated.
First in loads, last out unloads after Linger
Section titled “First in loads, last out unloads after Linger”Occupancy transitions drive the cycle:
- The first occupant loads the stage. Later occupants trigger nothing at the stage level while it stays loaded.
- The last occupant to leave does not unload it at once. Each stage holds one Scheduler deadline (
Scheduler:after,kernel.Priority.Low) that fires exactlyLingerseconds (default 10) after the stage empties, and the stage unloads then. Nothing polls: an empty stage holds one timer and a busy stage holds none. - Re-entry during the linger cancels that deadline, so the stage stays loaded as if it had never emptied. A later emptying starts a fresh one.
Without the linger, a player pacing across the zone edge would spawn and despawn the boss, the ambience and the bound Replica on every crossing.
The linger deadline uses the kernel Scheduler’s time. The injected Clock is used only by the manual step() that SkipLoop specs call: with SkipLoop = true, no deadline is scheduled, the stage records when it emptied on Clock, and step() unloads any loaded stage that has been empty for at least Linger.
OnEnter and OnLeave fire per occupant
Section titled “OnEnter and OnLeave fire per occupant”| Hook | Fires on | Frequency |
|---|---|---|
Load and its cleanup |
The occupant count crossing 0 to 1, and emptying past the linger | Once per occupancy episode |
OnEnter |
Each player entering the zone | Once per player per entry |
OnLeave |
Each player leaving the zone | Once per player per exit |
Five players walking in one at a time call OnEnter five times and Load once. For the first arrival, Load runs before OnEnter, so OnEnter can assume the stage’s resources exist. On the way out, OnLeave and Stage.Left run before the emptiness check that arms the linger.
Occupancy rides Zones’ Bus topics
Section titled “Occupancy rides Zones’ Bus topics”Stages runs no spatial queries. attach subscribes to Zone.Entered and Zone.Left on the Bus and routes them to every stage registered for that zone name. Each stage keeps its own occupant set and count from those events. A player who disconnects inside counts down automatically, because Zones publishes Zone.Left for every zone a player was inside at Kernel.SessionEnd.
Seeding standing occupants
Section titled “Seeding standing occupants”With a Zones instance passed to Stages.attach, define calls zones:playersIn(zoneName) right after registering the stage and enters every player already standing inside. A stage defined mid-session loads immediately for them, with no Zone.Entered involved. Without it, those players would have to leave and re-enter.
Replicas bound while loaded
Section titled “Replicas bound while loaded”Replicas lists existing Replica handles. On load, Stages calls replica:bindZone(zones, zoneName) for each and keeps the unbind functions; on unload, the unbinds run before the Load cleanup. A listed Replica has interest only from players in the zone, and only while the stage is loaded: outside that window there are no subscribers and no bytes. bindZone subscribes to the zone topics itself, so a player entering an already-loaded stage gets the snapshot at once.
Replicas needs the Zones instance: define errors without one (stage "X" uses Replicas; pass Zones at Stages.attach).
Failures are isolated per closure
Section titled “Failures are isolated per closure”Every user closure (Load, its cleanup, Unload, OnEnter, OnLeave, each bindZone and unbind) runs under pcall. A throw logs a warning naming the stage and the closure ("BossArena" Load failed: ...) and the lifecycle continues: other stages, other zones and the rest of this stage’s hooks run as if it had succeeded. The state transition happens regardless; only the closure’s result is lost.
A dungeon that spawns its boss on first entry, despawns it after the last player leaves, and binds a boss-state Replica to the zone while anyone is inside:
--!strict-- DungeonService: the dungeon boss exists only while players are in the dungeon.
local ReplicatedStorage = game:GetService("ReplicatedStorage")local ServerScriptService = game:GetService("ServerScriptService")local ServerStorage = game:GetService("ServerStorage")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)local NPCKit = require(ServerScriptService.ChloeKernelServer.Kits.NPCKit)local ReplicaService = require(ServerScriptService.ChloeKernelServer.Replica)local Stages = require(ServerScriptService.ChloeKernelServer.Stages)local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local Log = Logger.scope("DungeonService")
local DungeonService = { Name = "DungeonService",}
function DungeonService.init(self: any, kernel: any) self.Kernel = kernel self.Regions = Zones.attach(kernel) self.Npcs = NPCKit.attach(kernel, { Zones = self.Regions }) self.Replicas = ReplicaService.new(kernel) -- Zones here seeds standing occupants and enables Replicas self.Stages = Stages.attach(kernel, { Zones = self.Regions })end
function DungeonService.start(self: any) local Volume = workspace:FindFirstChild("DungeonVolume") local Template = ServerStorage:FindFirstChild("DungeonBoss") if not (Volume and Volume:IsA("BasePart") and Template and Template:IsA("Model")) then Log.warn("expected a BasePart at Workspace.DungeonVolume and a Model at ServerStorage.DungeonBoss") return end self.Regions:add("Dungeon", Volume) self.Npcs:define("DungeonBoss", { Model = Template, Health = 5000 }) local State = self.Replicas:create("DungeonState", { Schema = { Phase = "String" }, Data = { Phase = "Idle" }, }) self.Stages:define("Dungeon", { Zone = "Dungeon", Linger = 10, Load = function(_stage: any) local Boss = self.Npcs:spawn("DungeonBoss", Volume.CFrame) State:patch({ Phase = "Awake" }) return function() Boss:destroy() State:patch({ Phase = "Idle" }) end end, OnEnter = function(_stage: any, player: Player) self.Kernel.Bus:publish("Dungeon.Entered", player) end, Replicas = { State }, })end
function DungeonService.stop(self: any) self.Stages:destroy() -- runs every loaded stage's cleanup now, ignoring Linger self.Npcs:destroy() self.Regions:destroy()end
return DungeonServiceClient cosmetics follow the stage state. The Replica above already carries Phase; a client controller listening to DungeonState through ReplicaClient starts the dungeon drone when it reads "Awake" and stops it on "Idle". A soundscape scatters its sounds around the listener’s camera, so it belongs on each client’s own AudioKit rather than the server. The Stage.* topics can also be bridged to clients with BusBridge; they carry a stage name and a player, nothing secret.
API reference
Section titled “API reference”Stages.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
Zones |
nil |
A live Zones instance. Seeds standing occupants at define and is required for Replicas |
Clock |
os.clock |
Time source for the manual step() (spec seam) |
SkipLoop |
false |
Schedule no linger deadlines; call :step() yourself |
Stage definition (stages:define(name, definition)):
| Field | Type | Description |
|---|---|---|
Zone |
string |
Required. The Zones name whose occupancy drives the stage |
Load |
(stage) -> (() -> ())? |
Runs on the first occupant; returns the unload closure |
Unload |
(stage) -> () |
Runs after the returned cleanup at every unload |
OnEnter |
(stage, player) -> () |
Runs for each player entering, after Load for the first |
OnLeave |
(stage, player) -> () |
Runs for each player leaving |
Replicas |
{ Replica }? |
Handles bound to Zone while loaded; needs Zones at attach |
Linger |
number? |
Seconds a stage stays loaded after it empties. Default 10 |
| Member | Description |
|---|---|
stages:define(name, definition) -> stage |
Registers a stage. Errors on a duplicate name, a missing Zone, or Replicas without Zones |
stages:step() |
Manual linger check for SkipLoop: unloads every loaded stage empty for at least Linger on Clock |
stages:occupants(name) -> { Player } |
Current occupants; errors for an unknown stage |
stages:loaded(name) -> boolean |
Whether the stage is loaded; errors for an unknown stage |
stages:destroy() |
Unsubscribes from the zone topics and unloads every stage now, cancelling pending lingers |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Stage.Loaded |
stageName |
The stage loaded (first occupant, or a seeded occupant at define) |
Stage.Unloaded |
stageName |
The stage unloaded (linger expired, step(), or destroy()) |
Stage.Entered |
stageName, player |
A player entered the stage’s zone, after Load for the first |
Stage.Left |
stageName, player |
A player left the stage’s zone, before the emptiness check |
Consumed: Zone.Entered and Zone.Left. Every occupancy decision rides those two topics.