[ QuickZone V1.1.0]
60 FPS While ZonePlus and SimpleZone Drop To 5 FPS
99.6% Memory Reduction Compared To ZonePlus and 5x Smaller Than SimpleZone
*see Benchmarks section for more
Showcase Place | MarketPlace | Documentation | Github Repository | Roblox OSS Community
Overview
Instead of using the physics engine like Zoneplus, SimpleZone and LessSimpleZone, QuickZone just performs geometric calculations all by itself. It provides a predictable, budgeted, and flexible solution for zone detection while using Linear Bounding Volume Hierarchy (LBVH) in the backend. QuickZone makes it possible to track thousands of entities across hundreds of zones with very little impact on your frame rate and memory.
Core Features
- Lifecycle Management: Use the
observepattern for 100% reliable cleanup. There is no need for jugglingonEnteredandonExitedevents anymore (do note that QuickZone still supports Event-Driven Programming). - Track Anything: Track
BaseParts,Models,Attachments,Bones,Cameras, or even pure Lua tables through duck typing. If it has a position, QuickZone can track it. - Shape Support: Supports Blocks, Balls, Cylinders, Wedges and CornerWedges without relying on the collision meshes.
- Decoupled Architecture: Separate game logic from spatial instances. Bind behaviors to categories of entities (Players, NPCs, Projectiles) for a clean, scalable architecture.
- Budgeted Scheduler: Remove lag spikes by setting a hard frame budget (e.g., 1ms). Workload is smeared across frames to maintain a flat and predictable performance profile.
- Zero-Allocation Runtime: By using contiguous arrays and object pooling, QuickZone reduces GC pressure, avoiding memory-related stutters.
How It Works
QuickZone is not instance-bound. It uses a Group-Observer-Zone topology, separating who is being tracked from where the tracking occurs and how the system should respond.
The Secret to Performance
If you are coming from ZonePlus or SimpleZone, you are used to a āZone-Centricā model where the Zone itself handles the logic. QuickZone flips this:
- ZonePlus: āHey Zone, tell me when a player enters you.ā
- QuickZone: āHey Observer, tell me when this Group enters any of these Zones.ā
The Three Pillars
- Zones (Where): Mathematical boundaries (Static or Dynamic).
- Groups (Who): Collections of entities (Players, NPCs, Projectiles) with shared update rates.
- Observers (How): The logic bridge. Define behavior once (e.g., āSafeZone Logicā) and attach it to as many zones as you need.
Quick Start
The following example showcases a swim system. QuickZone supports two coding styles, so choose the one that fits your workflow.
Option A: The QuickZone Approach (Recommended)
Best for clean, modern code. You define relationships in a configuration table (Declarative) and use a single function to manage the active state (Lifecycle).
local QuickZone = require(game.ReplicatedStorage.QuickZone)
local Zone, Group, Observer = QuickZone.Zone, QuickZone.Group, QuickZone.Observer
-- Create a LocalPlayerGroup that automatically tracks the client's character (including respawns)
local myPlayer = Group.localPlayer()
-- Create an observer subscribed to that group.
-- Priority 42 ensures this logic overrides lower-priority overlaps.
local swimObserver = Observer.new({
priority = 42,
updateRate = 30,
precision = 0.1,
groups = { myPlayer }
})
-- Define behavior
swimObserver:observeLocalPlayer(function()
local character = Players.LocalPlayer.Character
if not character then return end
local humanoid = character:FindFirstChild('Humanoid')
if not humanoid then return end
-- On Enter
humanoid:SetStateEnabled(Enum.HumanoidStateType.Swimming, true)
humanoid:ChangeState(Enum.HumanoidStateType.Swimming)
-- Return cleanup (On Exit)
return function()
humanoid:ChangeState(Enum.HumanoidStateType.GettingUp)
end
end)
-- Find all current and future instances with the 'Water' tag and wrap them in a Zones object.
-- By passing the observer in the config, all created zones are attached automatically.
Zone.fromTag('Water', {
observers = { swimObserver }
})
Option B: The Classic Approach (ZonePlus / SimpleZone Style)
Best for familiarity or for migrating ZonePlus code to QuickZone. You manually āwireā objects together (Imperative) and use standard events like onEntered to trigger one-off actions (Event-Driven).
local QuickZone = require(game.ReplicatedStorage.QuickZone)
local Zone, Group, Observer = QuickZone.Zone, QuickZone.Group, QuickZone.Observer
local myPlayer = Group.localPlayer()
local swimObserver = Observer.new():setPriority(42):setUpdateRate(30):setPrecision(0.1)
-- Subscribe the logic to the group
swimObserver:subscribe(myPlayer)
-- Connect events
swimObserver:onLocalPlayerEntered(function(zone)
print('Entered water zone:', zone:getId())
-- Add swimming logic here
end)
swimObserver:onLocalPlayerExited(function(zone)
print('Exited water zone:', zone:getId())
-- Remove swimming logic here
end)
-- Create a Zones object from a folder of parts.
-- The returned 'zones' object allows you to manage the entire collection at once.
local zones = Zone.fromParts(workspace.WaterParts:GetChildren())
zones:attach(swimObserver)
Usage Guide
QuickZone is designed around a three-tier architecture: Zones (where), Groups (who), and Observers (how).
1. Zones (Where)
Zones represent physical areas in the world. They are mathematical boundaries that can be static (fixed in space) or dynamic (following a part). They can be created from existing parts or defined manually with a CFrame and Size.
Bulk Creation
The easiest way to create zones is using the bulk constructors. The fromParts, fromDescendants, fromChildren, and fromTag return a Zones collection object, which acts as a logical unit allowing you to manage multiple zones at once.
-- Create zones from a CollectionService tag
local lavaZones = Zone.fromTag('Lava', {
metadata = { damage = 10 },
observers = { damageObserver },
})
-- Create zones from an array of parts
local safeZones = Zone.fromParts(workspace.SafeZones:GetChildren())
-- Create zones from all BaseParts inside a Model or Folder
local hazardZones = Zone.fromDescendants(workspace.TrapModel)
-- Create zones from only the direct children of a Folder
local flatZones = Zone.fromChildren(workspace.FlatFolder)
-- You can attach an observer to the entire collection at once
safeZones:attach(invincibilityObserver)
Manual Creation
Useful for procedural generation or areas without physical parts.
local zone = Zone.new({
cframe = CFrame.new(0, 10, 0),
size = Vector3.new(10, 10, 10),
shape = 'Block',
isDynamic = true,
metadata = { Name = 'Lobby' }
})
Single & Dynamic Creation
For maximum perfomance, use isDynamic = true for zones attached to moving platforms, vehicles, or projectiles.
local trainZone = Zone.fromPart(workspace.TrainCarriage, {
isDynamic = true,
metadata = { route = 'North' },
observers = { trainObserver }
})
Updating Zones
If you create a zone manually or want to sync a dynamic zone to a new reference, use :syncToPart().
-- Manually move a dynamic zone
dynamicZone:setPosition(Vector3.new(0, 50, 0))
-- Sync a dynamic zone to its associated part's current CFrame, Size, and Shape
dynamicZone:syncToPart()
2. Groups (Who)
Groups are collections of entities (Parts, Models, Players, etc.).
Specialized Groups
QuickZone provides built-in abstractions that automatically handle player lifecyles.
-- Tracks all players in the server
local allPlayers = Group.players()
-- Tracks only the local player (client-side only)
local myPlayer = Group.localPlayer()
Custom Groups
For NPCs, projectiles, or vehicles, create a standard Group.
local projectiles = Group.new({
entities = workspace.Projectiles:GetChildren()
})
Managing Entities
You can add BaseParts, Models, Attachments, Bones, or tables with a Position.
-- Add a Model (tracks the PrimaryPart or Pivot)
enemies:add(npcModel)
-- Add a specific Attachment (tracks the exact point)
-- This is great for offsets if you do not want to track the middle of a part (e.g. sword tip)
enemies:add(sword.TipAttachment)
-- Add a table
local spell = { Position = Vector3.new(10, 5, 0) }
enemies:add(spell)
-- Clear the enemies group when done
enemies:clear()
3. Observers (How)
Observers act as the logic layer. They subscribe to Groups and attach to Zones to bridge spatial data with game behavior.
Setup
An Observer listens to its subscribed Groups and checks if they overlap with its attached Zones.
local observer = Observer.new({
updateRate = 60, -- Check up to 60 times a second
precision = 1.0, -- Only query if the entity moves more than 1 stud
priority = 5 -- Used to resolve overlapping zones
})
observer:subscribe(allPlayers)
healingZones:attach(observer)
Lifecycle Management
For logic that should persist while an entity is inside a zone (e.g., UI, music, status effects), use the observe methods. These accept a callback that returns a cleanup function, which runs automatically when the entity exits.
-- Generic observation
observer:observe(function(entity, zone)
print('Entered', entity)
local highlight = Instance.new('Highlight', entity)
return function()
print('Exited', entity)
highlight:Destroy()
end
end)
-- The callback fires when the first entity of a group enters, and the
-- returned cleanup function fires when the last entity of the group leaves.
observer:observeGroup(function(group, zone)
print('Group ' .. group:getId() .. ' has arrived!')
local boss = workspace.Boss:Clone()
boss.Parent = workspace
return function()
print('The group has been wiped out or left.')
boss:Destroy()
end
end)
-- Player specific
observer:observePlayer(function(player, zone)
local forceField = Instance.new('ForceField', player.Character)
return function()
forceField:Destroy()
end
end)
-- LocalPlayer specific
observer:observeLocalPlayer(function(zone)
local sound = workspace.Sounds.SafeZoneAmbience
sound:Play()
return function()
sound:Stop()
end
end)
Events
For logic that happens exactly once on entry or exit (e.g., playing a sound effect, dealing damage, analytics), use the event listeners.
-- Individual entity events
observer:onEntered(function(entity, zone)
print(entity.Name .. ' entered ' .. zone:getId())
end)
observer:onExited(function(entity, zone)
print(entity.Name .. ' exited ' .. zone:getId())
end)
-- Transition event (Fires when swapping between overlapping zones within the same observer)
observer:onTransitioned(function(entity, newZone, oldZone)
print(entity.Name .. ' seamlessly moved to a new zone without leaving the area!')
end)
-- Group-level events
observer:onGroupEntered(function(group, zone)
print('The first member of group ' .. group:getId() .. ' entered!')
end)
observer:onGroupExited(function(group, zone)
print('The last member of group ' .. group:getId() .. ' left!')
end)
-- Convenient player events
observer:onPlayerEntered(function(player, zone) ... end)
observer:onPlayerExited(function(player, zone) ... end)
observer:onPlayerTransitioned(function(player, newZone, oldZone) ... end)
observer:onLocalPlayerEntered(function(zone) ... end)
observer:onLocalPlayerExited(function(zone) ... end)
observer:onLocalPlayerTransitioned(function(newZone, oldZone) ... end)
Handling Overlapping Zones (Transitions vs. Priorities)
When zones physically overlap in the world, QuickZone offers two distinct architectural patterns to handle them, depending on your goal.
Observers use a priority system to handle overlapping zones. An entity ābelongsā to only one zone state per observer at a time when using priorities.
Pattern 1: The Data-Driven Pattern (Single Observer + Transitions)
Best for: Systems that share the exact same logic, but use different values (e.g., all Environmental Hazards, all Healing Zones, all XP Zones).
If a player walks from a Lava zone into an overlapping SuperLava zone attached to the same observer, they never actually āleftā the observerās overall coverage area. Therefore, onExited and onEntered will not fire. Instead, the engine fires an onTransitioned event.
This allows you to update metadata instantly!
hazardObserver:observePlayer(function(player, initialZone)
local currentDamage = initialZone:getMetadata().Damage or 10
local active = true
local disconnectTransition = hazardObserver:onPlayerTransitioned(function(transitioningPlayer, newZone, oldZone)
if transitioningPlayer ~= player then return end
currentDamage = newZone:getMetadata().Damage or 10
print(player.Name .. " transitioned. New damage: " .. currentDamage)
end)
task.spawn(function()
while active do
player.Character.Humanoid:TakeDamage(currentDamage)
task.wait(1)
end
end)
return function()
active = false
disconnectTransition() -- Clean up the listener
end
end)
Pattern 2: The State-Machine Pattern (Multiple Observers + Priorities)
Best for: Systems with mutually exclusive logic that need to strictly override each other (e.g., Camera Filters, Music Tracks, UI States).
If you have overlapping zones that do fundamentally different things, you should attach them to different observers and assign them a Priority. QuickZoneās engine will automatically force the entity out of the lower-priority observer and into the higher-priority one.
local lowPriority = Observer.new({ priority = 0 })
local highPriority = Observer.new({ priority = 10 })
-- If a player is inside Zone A (Low) and Zone B (High) simultaneously:
-- 1. highPriority:onEntered() fires for Zone B.
-- 2. lowPriority:onExited() fires for Zone A.
Observer State
Observers can be toggled to pause logic without destroying the configuration.
observer:setEnabled(false) -- Fires 'onExited' for everyone inside
task.wait(5)
observer:setEnabled(true) -- Fires 'onEntered' if they are still there
Utility
Frame Budget
To maintain a high framerate in complex scenes, you can constrain the total CPU time QuickZone is allowed to consume per frame.
-- Allow 0.5 milliseconds per frame (default is 1ms)
QuickZone:setFrameBudget(0.5)
Immediate Spatial Queries
Perform instant checks without using the Observer/Group pattern.
-- Get all zones at a specific vector
local zones = QuickZone:getZonesAtPoint(Vector3.new(10, 5, 0))
-- Get the group an entity belongs to
local group = QuickZone:getGroupOfEntity(workspace.Part)
Limitations
-
Point-Intersection: QuickZone tracks the precise coordinate of an entity. It does not account for the full volume of the entity itself.
-
Budgeted Latency: To prevent frame drops, QuickZone āsmearsā workload across multiple frames. In high-load scenarios (e.g., thousands of active entities), there may be a slight delay between an entity physically entering a zone and the event firing.
Benchmarks
We stress-tested QuickZone against the most popular alternatives in two distinct scenarios: Entity Stress (lots of moving parts) and Map Stress (lots of zones).
Note: For the QuickZone benchmark, we used a frame budget of 1ms, the entitiesā update rate was set to 60Hz, and precision was 0.0.
Test 1: High Zone Count
Scenario: 500 moving entities, 10,000 zones, recorded over 30 seconds.
This test highlights the fundamental flaw in traditional Zone-Centric libraries. As map complexity grows, their performance degrades exponentially.
| Metric | QuickZone | ZonePlus | SimpleZone | QuickBounds | Empty Script |
|---|---|---|---|---|---|
| FPS | 59.33 | 3.84 | 5.53 | 58.95 | 59.28 |
| Events/s | 648 | 627 | 519 | 328 | 0 |
| Memory Usage (MB) | 18.57 | 4230 | 99.79 | 17.62 | 0.65 |
The Result: QuickZone maintained a perfect 60 FPS.
-
ZonePlus and SimpleZone imploded, dropping to 3-5 FPS, making the game unplayable.
-
ZonePlus consumed over 4 GB of memory, which would crash most mobile devices instantly.
-
QuickZone proved it is O(N) relative to entities, not zones. You can add as many zones as you want without performance penalties.
-
QuickZone vs. QuickBounds: Both libraries scaled well by maintaining ~60 FPS. However, QuickZone still maintained a slight FPS lead and, more importantly, delivered double the event throughput (643 vs 328) compared to QuickBounds.
Test 2: High Entity Count
Scenario: 2,000 moving entities, 100 zones, recorded over 30 seconds.
| Metric | QuickZone | ZonePlus | SimpleZone | QuickBounds | Empty Script |
|---|---|---|---|---|---|
| FPS | 42.37 | 29.88 | 37.23 | 41.31 | 42.73 |
| Events/s | 2271 | 2482 | 2518 | 566 | 0 |
| Memory Usage (MB) | 2.13 | 159 | 1.77 | 2.60 | 1.04 |
The Result: QuickZone is the only library that maintained near-baseline FPS (-1% impact).
-
ZonePlus caused a 28% drop in framerate, and SimpleZone decreased by 13%.
-
QuickZone handled the load with 98% less memory than ZonePlus.
-
QuickZone vs. QuickBounds: QuickZone squeezes out more performance, averaging ~1 FPS higher than QuickBounds. More importantly, QuickZone processed 4x the volume of events (2,271 vs 566).
If you want to read more about QuickZoneās technical details, read āwhy use quickzoneā in the docs.
There may still be bugs. If you encounter one, write a comment down below or, preferably, create an āIssueā on GitHub.
QuickZone is free and open source! Contributors are welcome!
Showcase Place | MarketPlace | Documentation | Github Repository | Roblox OSS Community
