Skip to content

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.

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 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 exactly Linger seconds (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.

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.

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.

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 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).

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:

src/Server/Game/Services/DungeonService.luau
--!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 DungeonService

Client 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.

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
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.

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