Zones
Zones turns invisible parts into named regions that fire enter and leave events — for players and for any tracked entity (NPCs, props, vehicles). The defining decision is the detection method: cadenced volume queries, not Touched. Touched misses slow walks, fires once per ragdoll limb, and produces enter/leave chatter at the boundary. A sweep that asks “who is inside this volume right now?” and diffs against last sweep produces exactly one enter and one leave per crossing, always in a sane order.
Mental model
Section titled “Mental model”The sweep
Section titled “The sweep”attach schedules a sweep on the Scheduler at kernel.Priority.Low, every IntervalSeconds (default 0.25 s). Each sweep:
- Builds one include filter shared by every zone: each player’s character plus every tracked entity that is a real Instance.
- For each zone, queries each of its parts with
workspace:GetPartBoundsInBox(part.CFrame, part.Size, params)under an Include filter — the query only ever sees characters and tracked entities, so cost scales with occupant count, not world detail. Results across a zone’s parts union into one occupant set. - Resolves each hit part to an occupant: walk up the ancestor chain — the nearest tracked ancestor wins (a tracked model carried inside a character resolves as the entity, not the player); otherwise the first ancestor model that is a player’s character resolves to that player.
- Diffs against the zone’s previous occupant set. State settles first, events fire after the loop — a handler that adds or removes zones cannot mutate tables mid-iteration. Leaves dispatch before enters, so an occupant swapping zones is never momentarily “both inside”.
The OverlapParams is module-level and reused; FilterDescendantsInstances is reassigned only when the filter table’s identity changes, because the assignment is the expensive part.
Dispatch
Section titled “Dispatch”Every enter/leave dispatches three ways at once:
- A Bus topic —
Zone.Entered/Zone.Leftfor players,Zone.EntityEntered/Zone.EntityLeftfor tracked entities. Global, name-first payload. - The zone handle’s own
Entered/LeftSignals — so a handler for one zone doesn’t name-filter global topics. - The
OnEnter/OnLeavecallbacks passed toadd, run viatask.spawnso a throwing callback can’t halt the dispatch queue.
Whether an occupant classifies as a player or an entity is decided by tracked status at dispatch time — untrack dispatches its leaves before dropping the tracked flag so they still classify as entity events (spec-verified).
Lifecycle edges
Section titled “Lifecycle edges”- Session end: a Bus subscription on
Kernel.SessionEndfiresZone.Leftfor every zone the departing player was inside — leave handlers never miss a disconnect. remove(name)fires leaves for everyone inside before destroying the zone’s signals.- A tag-tracked entity that is destroyed or untagged fires its leaves immediately (Destroy clears tags, which counts as untagging). A manually tracked entity that despawns leaves on the next sweep.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServer
local Effects = require(Root.Effects)local Zones = require(Root.Zones)
return function(kernel) local Buffs = Effects.attach(kernel) Buffs:define("Protected", {}) -- until removed
local Regions = Zones.attach(kernel)
-- Per-zone handle: no name-filtering global topics local Safe = Regions:add("SafeZone", workspace.SafeZonePart, { OnEnter = function(player) Buffs:apply(kernel:getSession(player), "Protected") end, }) Safe.Left:connect(function(player) Buffs:remove(kernel:getSession(player), "Protected") end)
-- Or listen globally on the Bus (handlers get the zone name first) kernel.Bus:subscribe("Zone.Entered", function(_, name, player) print(player.Name, "entered", name) end)endAny invisible anchored part works as the volume; a list unions into one multi-part zone:
Regions:add("Lobby", { workspace.LobbyA, workspace.LobbyB })Regions:addPart("Lobby", workspace.LobbyAnnex) -- grow it laterZero-code zones from Studio tags
Section titled “Zero-code zones from Studio tags”Regions:addTagged("CKZone") -- returns a stop functionEvery part tagged CKZone becomes — or joins — the zone named by its ZoneName attribute (configurable via NameAttribute), falling back to the part’s Name. Parts resolving to the same name union into one multi-part zone. Tagging and untagging at runtime tracks live; a zone whose last part untags is removed (firing its leaves). Level designers place kill bricks and checkpoint volumes without touching code.
Tracking NPCs, props, vehicles
Section titled “Tracking NPCs, props, vehicles”local Untrack = Regions:track(BossModel) -- one entity; returns an untrack functionRegions:trackTag("Enemy") -- every CollectionService-tagged instance, live
kernel.Bus:subscribe("Zone.EntityEntered", function(_, name, entity) if name == "Objective" then Alarm:trip(entity) endend)NPCKit does this for you: attach it with a Zones reference and every spawned npc model is tracked, firing Zone.EntityEntered/Zone.EntityLeft as it patrols.
Patterns
Section titled “Patterns”Kill brick — the classic, minus the Touched bugs (no limb double-fires, no slow-walk misses):
Regions:add("Lava", workspace.LavaVolume, { OnEnter = function(player) local Humanoid = player.Character and player.Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid.Health = 0 end end,})Checkpoints — tag the volumes, store progress on the session (CheckpointKit builds this out fully, including respawn wiring):
Regions:addTagged("Checkpoint")kernel.Bus:subscribe("Zone.Entered", function(_, name, player) local Session = kernel:getSession(player) if Session then Session.Data.Checkpoint = name endend)Replica interest scoping — a Replica bound to a zone costs players outside it zero bytes; Zone.Entered delivers the full snapshot instantly and Zone.Left unsubscribes:
DungeonState:bindZone(Regions, "Dungeon") -- returns an unbind functionStreamingEnabled
Section titled “StreamingEnabled”Zones is indifferent to instance streaming: the sweep runs on the server, which always sees the full world — every zone part, every character, every tracked entity, regardless of what any client has streamed in. Enter/leave decisions never depend on client-side state. See StreamingEnabled.
API reference
Section titled “API reference”Zones.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
IntervalSeconds |
0.25 |
Sweep cadence |
Query |
bounds-box query | Injectable (part, filter?) → occupants (spec seam) |
SkipLoop |
false |
Do not schedule the sweep; drive _sweep() yourself |
| Member | Description |
|---|---|
zones:add(name, parts, options?) → ZoneHandle |
Create a zone from one part or a list; options = OnEnter?/OnLeave?. Duplicate names error |
zones:addPart(name, part) |
Add a part to an existing zone |
zones:remove(name) |
Remove a zone; fires leaves for everyone inside |
zones:addTagged(tag, options?) → stop |
Zones from CollectionService tags; options.NameAttribute default "ZoneName" |
zones:track(entity) → untrack |
Track one non-player entity |
zones:untrack(entity) |
Untrack; fires its leaves immediately as entity events |
zones:trackTag(tag) → stop |
Track every tagged instance, live |
zones:playersIn(name) → { Player } |
Untracked (player) occupants |
zones:entitiesIn(name) → { entity } |
Tracked occupants |
zones:isInside(name, occupant) → boolean |
Membership test (what bindZone uses under the hood) |
zones:destroy() |
Cancel the loop, disconnect tag listeners, destroy every handle signal |
ZoneHandle:
| Field | Description |
|---|---|
Name |
The zone name |
Entered |
Signal firing (occupant) |
Left |
Signal firing (occupant) |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Zone.Entered |
zoneName, player |
A player entered a zone |
Zone.Left |
zoneName, player |
A player left a zone (including disconnect and zone removal) |
Zone.EntityEntered |
zoneName, entity |
A tracked entity entered |
Zone.EntityLeft |
zoneName, entity |
A tracked entity left (including untrack/untag/Destroy) |
Consumed: Kernel.SessionEnd — dispatches leaves for the departing player.
Design notes
Section titled “Design notes”- Cadence is a latency/cost dial. 0.25 s is right for buffs, music regions, and objectives. A speedrun finish line that must be frame-accurate deserves a faster interval — or its own dedicated Zones instance, so the hot zone doesn’t drag every casual region to 60 Hz with it.
- Detection volume is the part’s oriented bounding box.
GetPartBoundsInBoxuses the part’s CFrame and Size — a rotated part detects as its rotated box, but a shaped part (sphere, wedge, mesh) still detects as its box. Compose multi-part zones for irregular areas. - An occupant is “inside the zone”, not “inside a specific part”. Multi-part zones dedupe: moving between two parts of the same zone fires nothing.
- Enter/leave fire on the sweep boundary. An occupant that enters and exits entirely between two sweeps is never seen. That is the deliberate trade against
Touched; shrink the interval if it matters.