Skip to content

Zones

Turns parts into named regions that publish enter and leave events for players and tracked entities, from cadenced server volume queries instead of Touched.

Realm
Server
Require
ServerScriptService.ChloeKernelServer.Zones
Since
v0.1.0
Source
Zones.luau

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 chatters at the boundary. A sweep that asks who is inside each volume now and compares with the last answer produces one enter and one leave per crossing, in a sane order. Stages loads resources from zone occupancy, Mounts can add tagged parts to zones, and Hazards covers moving, procedural danger areas.

A zone part is a trigger volume: CanCollide, CanQuery and CanTouch off. add, addPart and addTagged turn CanQuery and CanTouch off on every zone part they receive, so weapon rays, line-of-sight checks and touch handlers pass through a shop or safe-zone volume instead of stopping on it. Pass KeepQueryable = true to leave a part as authored, for a solid or touch-wired part that doubles as a zone (the Obby template’s kill bricks). Zones never changes CanCollide.

attach creates no timer. The first add starts a Scheduler job every IntervalSeconds (default 0.25 s) at Priority (default kernel.Priority.Low), and removing the last zone cancels it. Each sweep:

  1. Builds one include list shared by every zone: each player’s character plus every tracked entity that is an Instance (a tracked entity with a probe contributes its probe). Two lists alternate between sweeps, so no sweep allocates and consecutive sweeps hand out different table identities.
  2. Skips the rest when nothing can be found or released. With the built-in query, an empty include list (no characters, no tracked instances) and every zone already empty means every query would return nothing, so the sweep ends there.
  3. Queries each zone’s parts and unions the results into one occupant set per zone (see detection modes below).
  4. Resolves each hit part to an occupant by walking up its ancestors. The nearest tracked ancestor wins, so a tracked model carried by a character counts as the entity. Otherwise the first ancestor that is a player’s character resolves to that player; the include-list pass already mapped characters to players, so no per-hit GetPlayerFromCharacter call is needed except for a character that changed since.
  5. Compares each zone with its previous occupant set. State settles for every zone first and events fire after the loop, so a handler that adds or removes zones cannot change tables mid-iteration, and an isInside check from any handler already sees the new membership everywhere. Within a zone, leaves are queued before enters. Across zones, events follow the order the zones are visited, which is not defined.

Detection = "Parts" (the default) uses engine overlap queries with one OverlapParams per Zones instance, whose IncludeInstances is reassigned only when the include list’s identity changes:

  • A block part (a Part with Shape = Block) queries as its box: workspace:GetPartBoundsInBox(part.CFrame, part.Size, params). A rotated block detects as its rotated box.
  • Any other shape (balls, cylinders, wedges, meshes, unions) queries its exact volume with workspace:GetPartsInPart(part, params). That needs the part in the Workspace; a non-block part outside it falls back to its box.

Detection = "Bounds" measures instead of querying. Each occupant is a whole-body box (a part’s extents, or a model’s GetBoundingBox), indexed in a Spatial grid with 16-stud cells, and each zone part is its physical extents (ExtentsCFrame and ExtentsSize). A rotated box becomes the axis-aligned box that contains it, so Bounds errs toward “inside”. Bounds mode also skips the whole query pass when nothing that can change membership moved since the last sweep: no occupant position or extent, no occupant set, no zone part CFrame or size. On a quiet server with many zone parts (a plot per player), that is most sweeps.

An injected Query replaces both and always runs, since Zones cannot know what state it reads.

Every enter and leave dispatches three ways:

  • A Bus topic: Zone.Entered and Zone.Left for players, Zone.EntityEntered and Zone.EntityLeft for tracked entities, with the zone name first.
  • The zone handle’s own Entered and Left Signals, so a handler for one zone does not filter global topics by name.
  • The OnEnter and OnLeave callbacks given to add, through task.spawn so a throwing callback cannot stop the queue. With SynchronousCallbacks = true they run inline under xpcall instead, and a failure logs a warning naming the zone and callback.

Whether an occupant is 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 publish as entity events.

  • Session end: a Kernel.SessionEnd subscription publishes Zone.Left for every zone the player was inside, then removes their character from the Bounds index.
  • remove(name) publishes leaves for everyone inside, then destroys the zone’s Signals. Removing the last zone cancels the sweep and clears every internal table.
  • A zone removed by an event handler during dispatch drops the enters queued for it in that sweep; the leaves already queued are still delivered. remove publishes Left for everyone it considers inside, which includes occupants whose enter was queued but not yet delivered, so those occupants see a Left with no matching Entered.
  • A tag-tracked entity that is untagged, destroyed or moved out of the Workspace leaves at once. A manually tracked entity that despawns leaves on the next sweep.
src/Server/Game/Services/SafeZoneService.luau
--!strict
-- SafeZoneService: players inside the lobby volume cannot be damaged.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
local Zones = require(ServerScriptService.ChloeKernelServer.Zones)
local Log = Logger.scope("SafeZoneService")
local SafeZoneService = {
Name = "SafeZoneService",
Dependencies = { "StatusService" },
}
function SafeZoneService.init(self: any, kernel: any)
self.Kernel = kernel
self.Regions = Zones.attach(kernel)
end
function SafeZoneService.start(self: any)
local Kernel = self.Kernel
local Buffs = Kernel:getService("StatusService").Buffs
Buffs:define("Protected", { Stats = { DamageTaken = 0 } }) -- until removed
local Lobby = workspace:FindFirstChild("LobbyVolume")
if not (Lobby and Lobby:IsA("BasePart")) then
Log.warn("expected a BasePart at Workspace.LobbyVolume")
return
end
local Safe = self.Regions:add("Lobby", Lobby, {
OnEnter = function(player: Player)
local Session = Kernel:getSession(player)
if Session then
Buffs:apply(Session, "Protected")
end
end,
})
Safe.Left:connect(function(player: Player)
local Session = Kernel:getSession(player)
if Session then
Buffs:remove(Session, "Protected")
end
end)
end
function SafeZoneService.stop(self: any)
self.Regions:destroy()
end
return SafeZoneService

Damage code reads the stat: Amount * Buffs:statMultiplier(Session, "DamageTaken"). Any anchored part works as a volume, and a list unions into one multi-part zone:

Regions:add("Plaza", { workspace.PlazaNorth, workspace.PlazaSouth })
Regions:addPart("Plaza", workspace.PlazaAnnex) -- the zone must exist already
local StopZones = Regions:addTagged("CKZone") -- returns a stop function

Every BasePart tagged CKZone inside the Workspace becomes, or joins, the zone named by its ZoneName attribute (configurable with NameAttribute), falling back to the part’s Name. Parts that resolve to the same name union into one zone. Tagging, untagging and moving a part into or out of the Workspace are followed live through TagWatch, and a zone whose last part leaves is removed, publishing its leaves. A map or template kept in ServerStorage or ReplicatedStorage creates no zone until it moves into the world.

local Untrack = Regions:track(BossModel) -- one entity; returns an untrack function
local StopTracking = Regions:trackTag("Enemy") -- every tagged instance in the Workspace, live
Kernel.Bus:subscribe("Zone.EntityEntered", function(_topic: string, name: string, entity: Instance)
if name == "Vault" then
Kernel.Bus:publish("Alarm.Tripped", entity)
end
end)

track(entity, probe?) accepts a probe Instance to query in place of the entity: a small invisible part welded to a vehicle, for example, when the vehicle’s full part set is expensive to test. Events still name the entity. NPCKit tracks every spawned model when attached with Zones.

Checkpoints: tag the volumes and store progress on the session (CheckpointKit builds this out fully, including respawns):

Regions:addTagged("Checkpoint")
Kernel.Bus:subscribe("Zone.Entered", function(_topic: string, name: string, player: 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 nothing. Zone.Entered sends the snapshot at once and Zone.Left unsubscribes:

local Unbind = DungeonState:bindZone(Regions, "Dungeon")

Kill bricks: the Obby template wires Touched for the instant kill and also adds every brick as a zone with KeepQueryable = true. A client can delete a TouchInterest and stop its own character’s touches from registering; the server’s sweep still sees the body inside the brick, and it also covers bricks moved by CFrame and bricks authored with CanTouch off.

The sweep runs on the server, which sees every zone part, character and tracked entity regardless of what any client has streamed in. Enter and leave never depend on client state. See StreamingEnabled.

Zones.attach(kernel, options?) options:

Option Default Description
IntervalSeconds 0.25 Sweep interval while any zone exists
Priority kernel.Priority.Low Scheduler priority of the sweep
Detection "Parts" "Parts" for engine overlap queries, "Bounds" for whole-body extents with unchanged-sweep skipping
SynchronousCallbacks false Run OnEnter and OnLeave inline under xpcall instead of task.spawn
Query built in (part, filter?) -> { occupant }; replaces detection and always runs (spec seam)
SkipLoop false Never schedule the sweep; call _sweep() yourself
Member Description
zones:add(name, parts, options?) -> ZoneHandle Creates a zone from one part or a list. options: OnEnter?, OnLeave?, KeepQueryable?. A duplicate name errors, and so does a destroyed instance
zones:addPart(name, part) Adds a part to an existing zone, preparing it with the zone’s KeepQueryable. Errors for an unknown name
zones:remove(name) Removes a zone; publishes leaves for everyone inside
zones:addTagged(tag, options?) -> () -> () Zones from tagged BaseParts in the Workspace. options: NameAttribute? (default "ZoneName"), KeepQueryable?. Returns a stop function
zones:track(entity, probe?) -> () -> () Tracks a non-player entity, optionally through a probe Instance; returns an untrack function
zones:untrack(entity) Stops tracking; publishes its leaves at once as entity events
zones:trackTag(tag) -> () -> () Tracks every tagged instance while it is in the Workspace; returns a stop function
zones:playersIn(name) -> { Player } Untracked (player) occupants
zones:entitiesIn(name) -> { any } Tracked occupants
zones:isInside(name, occupant) -> boolean Membership test; Replica:bindZone uses it
zones:destroy() Cancels the sweep, stops tag watchers, destroys every handle’s Signals and clears all state. Publishes no leaves

ZoneHandle:

Member Description
handle.Name The zone name
handle.Entered Signal firing (occupant)
handle.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 at session end and zone removal
Zone.EntityEntered zoneName, entity A tracked entity entered
Zone.EntityLeft zoneName, entity A tracked entity left, including untrack, untag and leaving the Workspace

Consumed: Kernel.SessionEnd, which publishes leaves for the departing player.

  • Cadence trades latency for cost. 0.25 s suits buffs, music regions and objectives. A finish line that must be frame-accurate deserves a faster interval on its own Zones instance, so one hot zone does not drag every casual region to that rate.
  • Membership is per zone. Moving between two parts of one zone fires nothing.
  • An occupant that enters and leaves entirely between two sweeps is never seen. That is the trade against Touched; shrink the interval, or back the zone with Touched, where it matters.
  • Parts mode counts a character when any of its parts overlaps. Bounds mode compares whole-body boxes, so it reports “inside” slightly earlier at a zone’s edge.

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