Introduction
SimplerZone is the successor to LessSimpleZone & SimpleZone, a versatile & flexible spatial zone module for tracking where and when specific items enter specific areas of your game, doing spatial checks, etc.
To use the module, download the .rbxm binary from the releases of the GitHub.
The general hierarchy of objects inside the SimplerZone library:

Misc Types
A collection of smaller utility types the module uses.
Definition
export type Shape = BoxShape | CylinderShape | SphereShape | WedgeShape | GroupShape | MeshShape
export type Disconnector = () -> ()
export type ZoneItems = {
parts: {BasePart},
models: {Model},
players: {Player},
attachments: {Attachment},
pvinstances: {PVInstance},
}
export type ZoneItem = {
part: BasePart?,
model: Model?,
player: Player?,
attachment: Attachment?,
pvinstance: PVInstance?,
}
Shape
A Shape is an elementary object that defines, well, the shape of your Zones. Shapes aren't defined by Instances with the exception of MeshShape, so you can create and use them even without explicitly making parts for them inside the workspace.
Most shapes will have a .transform property, this property determines the offset of the Shape from the .cframe of the ShapeInstance that uses it. In the case of SphereShapes, .transform isn't present and instead is replaced by .center
During usage of Shapes within ShapeInstances, it is gauranteed that the properties of your Shapes will not change. So it is okay to reference 1 Shape multiple times
BoxShape
Definition
{
ty: "Box",
transform: CFrame,
size: vector
}
SphereShape
Definition
{
ty: "Sphere",
center: vector,
radius: number
}
CylinderShape
Definition
{
ty: "Cylinder",
center: vector,
axis: vector,
radius: number,
transform: CFrame,
size: vector,
}
WedgeShape
Definition
{
ty: "Wedge",
transform: CFrame,
size: vector,
vertices: {vector}
}
MeshShape
Definition
{
ty: "Mesh",
transform: CFrame,
size: vector,
mesh: MeshPart
}
GroupShape
Definition
{
ty: "Group",
transform: CFrame,
shapes: {Shape}
}
ShapeInstance
If Shapes were akin to Mesh Ids, then ShapeInstances would be MeshParts.
ShapeInstances define an instance of a Shape, specifying its world .cframe, and .scale.
Definition
{
toParts: (ShapeInstance, overrideCFrame: CFrame?) -> {BasePart},
syncWithPart: (ShapeInstance, part: BasePart) -> Disconnector,
syncWithAttachment: (ShapeInstance, attachment: Attachment) -> Disconnector,
attach: (ShapeInstance, zone: Zone) -> (),
detach: (ShapeInstance) -> (),
cframe: CFrame,
scale: vector,
zones: {Zone},
shape: Shape,
}
Methods
ShapeInstance:attach(zone: Zone) -> ()
Attaches this ShapeInstance to zone, adding to its geometry.
ShapeInstance:detach(zone: Zone) -> ()
Removes this ShapeInstance from the zone, removing from its geometry.
ShapeInstance:detachAll() -> ()
Removes this ShapeInstance from all attached zones
ShapeInstance:toParts(overrideCFrame: CFrame? = CFrame.identity) ->
Creates an array of BaseParts from a ShapeInstance. The world CFrame will be centered around overrideCFrame
ShapeInstance:syncWithPart(part: BasePart) -> ()
Syncs the ShapeInstance with a BasePart. When the BasePart moves, so does the ShapeInstance, updating any Zones it's attached to.
ShapeInstance:syncWithAttachment(attachment: Attachment) -> ()
Syncs the ShapeInstance with an Attachment. When the Attachment moves (.WorldCFrame), so does the ShapeInstance, updating any Zones it's attached to.
Fields
ShapeInstance.shape: Shape
Defines the form/shape of this ShapeInstance.
ShapeInstance.cframe: CFrame
The world CFrame of this ShapeInstance. The .shape field will be transformed by this fields value.
When mutating this field, make sure to call Zone:rebuild() on all Zones inside ShapeInstance.zones so that the change registers properly.
ShapeInstance.scale: vector
The scale modifier to apply to the .shape fields values size.
ShapeInstance.zones:
All Zones that this ShapeInstance is attached to.
Zone
Zones are the main object of the SimplerZone library. Zones are composed of ShapeInstances and contain methods to manipulate/use them.
Definition
{
getRandomPoint: (Zone) -> vector,
isPointInZone: (Zone, point: vector) -> boolean,
isBoxInZone: (Zone, cframe: CFrame, size: vector) -> boolean,
rebuild: (Zone) -> (),
rebuildIfDirty: (Zone) -> (),
shapes: {ShapeInstance},
bvh: Geometry.BvhNode,
boxes: {Geometry.Box},
bvhDirty: boolean,
}
Methods
Zone:getRandomPoint() -> vector
Returns a random point inside any of the ShapeInstances this Zone is composed of.
Zone:isPointInZone(point: vector) -> boolean
Checks if point is inside any of the ShapeInstances this Zone is composed of.
Zone:isBoxInZone(cframe: CFrame, size: vector) -> boolean
Checks if a box, defined by cframe and size, is inside any of the ShapeInstances this Zone is composed of.
Zone:rebuild() -> ()
Rebuilds the static geometry of this Zone. Should be called if any of the ShapeInstances this Zone is composed of is mutated in some way.
Zone:rebuildIfDirty() -> ()
Calls Zone:rebuild() if Zone.bvhDirty is true.
Fields
--
Zone.shapes:
The ShapeInstances this Zone is composed of.
Zone.bvh: BvhNode
The root BvhNode this Zone uses to optimize bounds checking.
Zone.bvhDirty: boolean
If Zone.bvh should be rebuilt at some point.
Zone.boxes:
The bounding box of each ShapeInstance in Zone.shapes.
ZoneListener
A ZoneListener is an object that can listen to when certain items enter the geometric boundaries of a Zone defined by its ShapeInstances.
Definition
setmetatable<{
onEnter: (ZoneListener, fn: (item: ZoneItem) -> ()) -> Disconnector,
onExit: (ZoneListener, fn: (item: ZoneItem) -> ()) -> Disconnector,
observe: (ZoneListener, fn: (item: ZoneItem) -> (() -> ())?) -> Disconnector,
setGroups: (ZoneListener, groups: {TrackGroup}) -> (),
subscribe: (ZoneListener, zone: Zone) -> (),
unsubscribe: (ZoneListener, zone: Zone) -> (),
unsubscribeAll: (ZoneListener) -> (),
precision: "Part" | "BoundingBox",
queryShape: "Point" | "Box",
groups: {TrackGroup},
zones: {Zone},
itemsInside: ZoneItems,
iter: (ZoneListener) -> () -> ZoneItem,
data: ZoneListenerData
}, {__iter: (ZoneListener) -> () -> ZoneItem}>
Methods
ZoneListener:subscribe(zone: Zone) -> ()
Subscribes this ZoneListener to the Zone, making any item this listener is tracking who enters the geometric boundaries of the Zone fire events.
ZoneListener:unsubscribe(zone: Zone) -> ()
Unsubscribes this ZoneListener from zone, cleaning up any internal listeners and events in the process.
ZoneListener:unsubscribeAll() -> ()
Unsubscribes this ZoneListener from all Zones it's subscribed to.
ZoneListener:onEnter(fn: (item: ZoneItem) -> ()) -> Disconnector
Connects fn to be called when any ZoneItem enters the geometric boundaries of any subscribed Zone
ZoneListener:onExit(fn: (item: ZoneItem) -> ()) -> Disconnector
Connects fn to be called when any ZoneItem exits the geometric boundaries of any subscribed Zone
ZoneListener:observe(fn: (item: ZoneItem) -> (() -> ())?) -> Disconnector
Connects fn to be called when any ZoneItem enters the geometric boundaries of any subscribed Zone, and for the returned function (if any) to be called when it exits it.
ZoneListener:setGroups(groups: {TrackGroup}) -> ()
Sets the TrackGroups that this ZoneListener should be listening for.
ZoneListener:iter() -> () -> ZoneItem
Iterates through all ZoneItems that are geometrically inside the Zones the ZoneListener is currently subscribed to.
Fields
ZoneListener.precision: "Part" | "BoundingBox"
Determines how precise the collision of TrackGroup items should be.
| Precision | Description |
|---|---|
"Part" | In player items, model items, pvinstance items etc, the collision will be determined by the collision of their individual parts instead of the bounding box. So for example if a players arm extends into a zone such that the bounding box intersects it but no part actually touches it, this will not trigger an entry event. |
"BoundingBox" | In player items, model items, pvinstance items etc, the collision will be determined by the collision of their respective bounding boxes instead of the parts they're composed of. This is faster but can cause false-entries such as when specific pokey parts of a model extend the bounding box of it such that the bounding box intersects your zone but no part of it actually does. |
ZoneListener.queryShape: "Point" | "Box"
Determines what method should be used to determine if a tracked item is in a zone or not.
| Query Shape | Description |
| "Point" | Items will be inside the zone if their center points collide with it. |
| "Box" | Items will be inside the zone if their bounding box collides with it. |
ZoneListener.itemsInside: ZoneItems
All the items geometrically inside the Zones this ZoneListener is listening to.
ZoneListener.groups:
TrackGroups being tracked by this ZoneListener.
ZoneListener.zones:
Zones this ZoneListener is subscribed to.
TrackGroup
TrackGroups are containers that contain items that ZoneListeners should be tracking for when they enter/exit the Zones it's listening to.
Definition
{
update: (TrackGroup) -> (),
removeFrom: (TrackGroup, listener: ZoneListener) -> (),
addItem: (TrackGroup, item: ZoneItem) -> (),
addItems: (TrackGroup, items: ZoneItems) -> (),
removeItem: (TrackGroup, item: ZoneItem) -> (),
removeItems: (TrackGroup, items: ZoneItems) -> (),
clear: (TrackGroup) -> (),
mutable: ZoneItems,
immutable: ZoneItems,
listeners: {ZoneListener}
}
Methods
TrackGroup:update() -> ()
Moves items inside TrackGroup.mutable into TrackGroup.immutable, updating any listeners in the process.
TrackGroup:removeFrom(listener: ZoneListener)
Removes this TrackGroup from the listener, making it no longer tracked for that listener.
TrackGroup:addItem(item: ZoneItem) -> ()
Tracks the items inside ZoneItem, updating any listeners in the process.
TrackGroup:addItems(item: ZoneItems) -> ()
Tracks the items inside ZoneItems, updating any listeners in the process.
TrackGroup:removeItem(item: ZoneItem) -> ()
Untracks the items inside ZoneItem, updating any listeners in the process.
TrackGroup:removeItems(items: ZoneItems) -> ()
Untracks the items inside ZoneItems, updating any listeners in the process.
TrackGroup:clear() -> ()
Removes all tracked items within this TrackGroup.
Fields
TrackGroup.mutable: ZoneItems
A mutable list of tracked items that have not yet been registered into listeners.
TrackGroup.immutable: Zoneitems
An immutable list of tracked items that have been registered into listeners.
TrackGroup.listeners:
All ZoneListeners currently tracking this TrackGroup.
Examples
Some simple examples to help you grasp how to use SimplerZone in common usecases.
--!strict
local Zone = require("@game/ReplicatedStorage/SimplerZone")
local Players = game:GetService("Players")
-- Build zone from all parts inside workspace.SafeZone
local shapeInstances = {}
for _, part in workspace.SafeZone:GetChildren() do
assert(part:IsA("BasePart"))
table.insert(shapeInstances, Zone.shapeInstance.fromPart(part))
end
local safeZone = Zone.new(shapeInstances)
local listener = Zone.listener.new({
groups = { Zone.defaultGroups.players },
zones = { safeZone },
precision = "BoundingBox",
queryShape = "Point",
})
listener:observe(function(item)
local plr = item.player
if not plr then return end
-- Grant invincibility on enter
plr:SetAttribute("InSafeZone", true)
print(`[SafeZone] {plr.Name} is now protected`)
return function()
-- Revoke invincibility on exit
plr:SetAttribute("InSafeZone", false)
print(`[SafeZone] {plr.Name} left protection`)
end
end)
--!strict
local Zone = require("@game/ReplicatedStorage/SimplerZone")
-- Build zone from all parts inside workspace.KillZone
local shapeInstances = {}
for _, part in workspace.KillZone:GetChildren() do
assert(part:IsA("BasePart"))
table.insert(shapeInstances, Zone.shapeInstance.fromPart(part))
end
local killZone = Zone.new(shapeInstances)
local listener = Zone.listener.new({
groups = {Zone.defaultGroups.players},
zones = {killZone},
precision = "Part", -- If any part of the player touches the killzone it should register
queryShape = "Box", -- Detect the whole part, not just the center
})
listener:onEnter(function(item)
local plr = item.player
local character = item.model
if not plr or not character then return end
-- Kill the player immediately on entry
local humanoid = character:FindFirstChildWhichIsA("Humanoid")
if humanoid then
humanoid.Health = 0
print(`[KillZone] {plr.Name} was eliminated`)
end
end)
--!strict
local Zone = require("@game/ReplicatedStorage/SimplerZone")
local hitboxShape = Zone.shape.new("Box", CFrame.identity, vector.create(4, 6, 4))
local hitboxZone = Zone.new()
local listener = Zone.listener.new({
groups = {Zone.defaultGroups.players},
zones = {hitboxZone},
precision = "BoundingBox",
queryShape = "Point",
})
-- Tick damage while player overlaps the hitbox
listener:observe(function(item)
local plr = item.player
local character = item.model
if not plr or not character then return end
local humanoid = character:FindFirstChildWhichIsA("Humanoid")
print(`[Hitbox] {plr.Name} entered {enemy.Name}'s attack range`)
local connection = game:GetService("RunService").Heartbeat:Connect(function()
if humanoid and humanoid.Health > 0 then
humanoid:TakeDamage(damage)
end
end)
return function()
connection:Disconnect()
print(`[Hitbox] {plr.Name} escaped {enemy.Name}'s attack range`)
end
end)
-- Attach a damage hitbox to a single enemy NPC
local function attachEnemyHitbox(enemy: Model, damage: number)
local rootAttach = assert(enemy:FindFirstChild("RootAttachment", true))
local shapeInst = Zone.shapeInstance.new(hitboxShape)
-- Keep the shape synced as the enemy moves
shapeInst:syncWithAttachment(rootAttach)
shapeInst:attach(hitboxZone)
hitboxZone:rebuild()
end
-- Example: attach hitbox to every enemy in workspace.Enemies
for _, enemy in workspace.Enemies:GetChildren() do
if enemy:IsA("Model") then
attachEnemyHitbox(enemy, 5) -- 5 damage per heartbeat tick
end
end