Skip to content

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.

attach schedules a sweep on the Scheduler at kernel.Priority.Low, every IntervalSeconds (default 0.25 s). Each sweep:

  1. Builds one include filter shared by every zone: each player’s character plus every tracked entity that is a real Instance.
  2. 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.
  3. 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.
  4. 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.

Every enter/leave dispatches three ways at once:

  • A Bus topicZone.Entered/Zone.Left for players, Zone.EntityEntered/Zone.EntityLeft for tracked entities. Global, name-first payload.
  • The zone handle’s own Entered/Left Signals — so a handler for one zone doesn’t name-filter global topics.
  • The OnEnter/OnLeave callbacks passed to add, run via task.spawn so 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).

  • Session end: a Bus subscription on Kernel.SessionEnd fires Zone.Left for 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.luau
local 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)
end

Any 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 later
Regions:addTagged("CKZone") -- returns a stop function

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

local Untrack = Regions:track(BossModel) -- one entity; returns an untrack function
Regions:trackTag("Enemy") -- every CollectionService-tagged instance, live
kernel.Bus:subscribe("Zone.EntityEntered", function(_, name, entity)
if name == "Objective" then
Alarm:trip(entity)
end
end)

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.

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

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 function

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.

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

  • 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. GetPartBoundsInBox uses 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.