Hazards
Hazards turns “a dangerous area” into a procedural volume: an annulus band — inner radius, outer radius, vertical height — evaluated as a pure distance check in a cadenced sweep. There is no part to spawn, resize, or destroy, and no Region3 or physics trigger. Where Zones wraps a designer-placed BasePart and asks “who overlaps this part’s bounding box,” Hazards asks “who is within this many studs of this point, right now” — cheap enough to re-evaluate every occupant every sweep, and just as easily anchored to a point that moves. A fire ring that expands over its own lifetime, a poison disc that follows the caster, a shrinking safe-zone wall — all three are the same annulus test with a different Inner/Outer function.
The other defining decision is the anti-tunneling occupancy test: a hazard checks each occupant’s last-position → current-position segment against the band, not just their current point. A dynamic ring is often thin and a sprint covers a lot of ground between two sweep samples — a point test can miss the crossing entirely. Hazards cannot miss it; the worst case is a legitimately fast crossing collapsing into a single tick’s Entered immediately followed by Left, instead of silently landing zero events.
Mental model
Section titled “Mental model”The band, as a pure function
Section titled “The band, as a pure function”Hazards.inBand(point, anchor, inner, outer, height) is the whole geometric test, and it is exported as a pure function precisely so HazardClient can run the identical check for its own local prediction (it doesn’t need to — it draws instead — but nothing stops a game from calling it client-side):
function Hazards.inBand(point: Vector3, anchor: Vector3, inner: number, outer: number, height: number): boolean if math.abs(point.Y - anchor.Y) > height then return false end local Radial = Vector3.new(point.X - anchor.X, 0, point.Z - anchor.Z).Magnitude return Radial >= inner and Radial <= outerendBoth radii and the height are inclusive boundaries: a point exactly at outer studs out, or exactly height studs above the anchor, counts as inside. Inner defaults to 0 (an Inner of 0 is a filled disc, not a ring), and Height defaults to 6 — the band spans anchor.Y - height to anchor.Y + height, a 12-stud vertical slab centered on the anchor, not a 6-stud one.
Radii as functions of age
Section titled “Radii as functions of age”export type Radius = number | ((age: number) -> number)Inner and Outer are each either a plain number or a function. When it’s a function, it receives exactly one argument: age — seconds elapsed since the hazard spawned (Now - Handle.SpawnedAt, using the manager’s own Clock). Nothing else rides that call — no seed, no position, no occupant. An expanding fire ring is:
Hazard:define("FireRing", { Inner = function(age) return math.max(0, age * 6 - 4) end, -- a hollow core opens after ~0.7s Outer = function(age) return age * 6 end, -- the rim expands at 6 studs/s Height = 8, Duration = 5,})radiusAt(radius, age, fallback) is the resolver both the server Hazards module and the client HazardClient module carry — a plain number returns unchanged, nil returns the fallback (0 for Inner, and the definition never reaches sweep without an Outer), a function is called with age. Keep the same Inner/Outer function objects in one shared module required by both bootstraps — exactly Projectiles’s convention for its numeric definition ids, and for the same reason: the server’s occupancy test and the client’s ring mesh must age from the identical curve, or the visual boundary lies about where the damage boundary actually is.
Anchors: a point, or a live Instance
Section titled “Anchors: a point, or a live Instance”SpawnOptions.Anchor is either a Vector3 (a fixed point — a rune circle, a trap) or an Instance (a moving anchor — bound to a caster’s root, a boss’s torso). Resolution happens once per hazard per sweep, in anchorPosition:
| Anchor value | Resolves to |
|---|---|
Vector3 |
Itself, unchanged |
Instance with a nil Parent (destroyed) |
nil — checked first, before any type check |
BasePart (still parented) |
.Position |
Model (still parented) |
:GetPivot().Position |
duck-typed table with a Position: Vector3 field |
.Position (the spec double shape) |
| anything else | nil |
Every sweep re-resolves a live Instance anchor, so a band bound to a caster’s HumanoidRootPart visibly drags along as the caster walks — the spec’s “moving anchors carry the band” case confirms exactly this: a duck-typed caster double walks toward a standing occupant and the band’s Entered fires only once it arrives close enough.
Retirement is a per-sweep liveness check, not a Destroying connection. There is no Instance.Destroying:Connect anywhere in Hazards.luau. Instead, every sweep calls anchorPosition(Handle.Anchor) before touching the band math, and:
local Anchor = anchorPosition(Handle.Anchor)if not Anchor then -- The bound instance is gone (caster despawned): retire self:_retire(Serial) continueenda nil result retires the hazard immediately, before any occupant is tested that sweep.
The death signal is Parent, not Position, and it’s checked before the type dispatch. A Destroy()’d BasePart or Model keeps answering Position/GetPivot() with its last held value forever — Destroy() doesn’t error those calls or return nil, it just sets Parent to nil and locks the instance while the strong reference Handle.Anchor still holds keeps working. anchorPosition resolves this way:
if typeof(anchor) == "Instance" then -- A Destroy()'d instance still answers Position with its last value; -- the nil Parent is the liveness signal if not anchor.Parent then return nil end if anchor:IsA("BasePart") then return anchor.Position end if anchor:IsA("Model") then local Pivot = anchor:GetPivot() return Pivot.Position endendThe Parent == nil check sits inside the Instance branch and runs first — it doesn’t replace the BasePart/Model type dispatch, it gates it. A destroyed instance returns nil immediately regardless of its type; only a still-parented Instance falls through to the BasePart/Model checks, and an Instance that’s live but neither of those still returns nil the same as before (there are two distinct ways an Instance anchor resolves to nil — destroyed, or the wrong type — and both retire the hazard identically). In practice, a caster’s HumanoidRootPart that gets Destroy()’d on ragdoll-and-respawn now retires the hazard on the very next sweep instead of leaving the band parked at the death location forever. Hazards.spec.luau’s “a Destroy()’d REAL anchor instance retires on the next sweep” case proves this against an actual Instance.new("Part") parented to workspace, not a duck-typed double: the hazard stays alive sweep-to-sweep while the part is parented, and Anchor:Destroy() followed by one more sweep() fires Hazard.Retired with no Hazard.Left (there was no occupant inside).
Serial allocation: wraparound skips still-live serials
Section titled “Serial allocation: wraparound skips still-live serials”hazard:spawn’s wire serial rides NumberU16 on CKHZ_Spawn, so NextSerial cycles modulo 65535. Allocation doesn’t just take the next counter value unconditionally — it walks forward until it finds a serial that isn’t currently in self.Live, the same allocator stance Projectiles’s allocateSerial takes:
local Serial = 0for _ = 1, 65535 do local Candidate = self.NextSerial % 65535 + 1 self.NextSerial = Candidate if self.Live[Candidate] == nil then Serial = Candidate break endendif Serial == 0 then return nilendA wrapped counter landing on a serial that’s still live (an extreme case — 65,535 concurrently live hazards) skips past it instead of overwriting self.Live[Candidate] and silently orphaning the old hazard. spawn returns nil from this path only when the loop exhausts all 65,535 attempts without finding a free slot — every serial genuinely in flight at once. The spec’s “serial wraparound skips still-live serials instead of orphaning” case confirms it directly: forcing NextSerial back onto a still-live serial 1 yields serial 2 for the next spawn, and Manager.Live[1] still points at the original handle.
The sweep, in order
Section titled “The sweep, in order”sweep() runs TickRate times a second (default 8, i.e. every 0.125s — noticeably faster than Zones’s 0.25s default, because a hazard band is often thin and moving) on the Scheduler at kernel.Priority.Low. Per live hazard, in this order:
- Duration check first. If
RetiresAthas passed, retire and move to the next hazard — the anchor is never even resolved for an already-expired hazard. - Anchor liveness. Resolve the anchor to a
Vector3; anilresult retires (see above). - Age the radii.
Inner/Outer/Heightresolve for this sweep’sage. - Per occupant, using the roster from
GetOccupants()(default: everyPlayers:GetPlayers()with a non-nilCharacter— Hazards, unlike Zones, does not track arbitrary NPCs/props out of the box; supply a customGetOccupantsreturning anything shaped{ Character }to extend it):- Read the occupant’s current position (
Character.HumanoidRootPart.Position, or the duck-typed equivalent) and their last recorded position (defaulting to the current position on first sight, so a hazard spawning under someone already standing in it does not falsely register a crossing). - Test
inBandfor “inside now,” and — only when not already inside —segmentCrossesBandfrom last position to current position. - Diff against last sweep’s membership and dispatch (see below).
- Read the occupant’s current position (
State updates (Handle.Inside[Occupant], Handle.LastPositions[Occupant]) happen inline with the dispatch in this module, unlike Zones’ explicit “settle state, then fire” two-pass split — there is no callback re-entrancy hazard here because Hazards has no per-hazard OnEnter/OnLeave callbacks, only Bus publishes.
Duration auto-retire: Left always beats Retired
Section titled “Duration auto-retire: Left always beats Retired”When Duration (from SpawnOptions.Duration, falling back to the Definition’s) elapses, _retire runs this exact sequence:
function Hazards._retire(self: any, serial: number) local Handle = self.Live[serial] if not Handle then return end self.Live[serial] = nil -- Everyone still inside leaves before the hazard dies for Occupant in Handle.Inside do self.Kernel.Bus:publish("Hazard.Left", Handle, Occupant) end table.clear(Handle.Inside) self.RetirePacket:Fire(serial) self.Kernel.Bus:publish("Hazard.Retired", Handle)endEvery occupant still marked inside gets Hazard.Left before Hazard.Retired publishes, and the handle is already gone from self.Live by the time either fires. The spec’s “duration retires with occupants inside” case confirms the exact order: Hazard.Left:Stander then Hazard.Retired:-. This ordering is why a damage-on-Left handler is safe to write naively — a cleanse-on-leave Effect handler that fires after Retired would be reading state for a hazard the rest of the game already considers gone (zones:isInside-style membership queries, UI, anything keyed off “is this hazard still live” would already say no). Left-before-Retired means the handler’s own bookkeeping never races the hazard’s teardown.
This same ordering applies to Stop() — it calls _retire directly, so a manually-stopped hazard flushes everyone’s Left the same way a duration expiry does.
The anti-tunneling mechanic
Section titled “The anti-tunneling mechanic”A point-in-time occupancy test — “is the occupant inside the band right now” — has a hole: if a thin ring’s entire width is crossed between two sweep samples, the occupant is outside on both samples and the crossing is invisible. At TickRate = 8 (0.125s between sweeps) a 30 studs/s sprint covers ~3.75 studs per sweep — enough to fully clear a 2-stud-wide ring undetected by point sampling alone.
Hazards.segmentCrossesBand(a, b, anchor, inner, outer, height) closes this by testing the occupant’s motion segment, not their point, whenever the point test alone says “not inside”:
function Hazards.segmentCrossesBand(a, b, anchor, inner, outer, height): boolean if math.min(a.Y, b.Y) > anchor.Y + height or math.max(a.Y, b.Y) < anchor.Y - height then return false end local AX, AZ = a.X - anchor.X, a.Z - anchor.Z local BX, BZ = b.X - anchor.X, b.Z - anchor.Z local DX, DZ = BX - AX, BZ - AZ local LengthSquared = DX * DX + DZ * DZ local T = 0 if LengthSquared > 1e-9 then T = math.clamp(-(AX * DX + AZ * DZ) / LengthSquared, 0, 1) end local CX, CZ = AX + DX * T, AZ + DZ * T local Nearest = math.sqrt(CX * CX + CZ * CZ) local Farthest = math.max(math.sqrt(AX * AX + AZ * AZ), math.sqrt(BX * BX + BZ * BZ)) return Nearest <= outer and Farthest >= innerendRead it as two exact 2D (X/Z-plane) tests plus one coarse height test:
- Radial reach is exact.
Nearestis the true closest distance from the anchor to the finite segment (project the anchor onto the segment’s line, clamp the parameterTto[0, 1]so the closest point can’t fall outside the actual travelled path, then measure).Farthestis the greater of the two endpoints’ radial distances — correct because distance-squared along a straight segment is a convex function of position, so its maximum over the segment always lands at an endpoint, never the interior.Nearest <= outer and Farthest >= inneris true exactly when the straight path swept through the annulus at some point betweenaandb. - The height gate is a coarse over-approximation, by design. It only rejects when the entire segment’s Y-range falls entirely above or entirely below the slab — a segment that dips through the slab anywhere in its Y-range passes, even if the specific point where it’s radially inside the ring happens to be outside the height band. The source comment is explicit that this over-includes only for a steep segment crossing the slab outside the ring, which is an acceptable trade for character motion between two samples 0.125s apart.
A full through-crossing fires Entered then Left in the same sweep. When the point test says “not inside now” but segmentCrossesBand says the path swept through, and the occupant wasn’t already marked inside, the sweep publishes both events back-to-back in that tick:
elseif Crossed and not WasInside then -- Sprinted through between samples: touch still lands self.Kernel.Bus:publish("Hazard.Entered", Handle, Occupant) self.Kernel.Bus:publish("Hazard.Left", Handle, Occupant)endNote Handle.Inside[Occupant] is never set to true on this branch — the occupant’s membership state ends the sweep exactly as it started (not inside), only the events fired as if they had briefly been inside. The spec’s “a sprint through a thin ring cannot tunnel” case is the precise confirmation: a 14–16 stud ring, one sample outside at x = -40, the next sample (one 0.25s tick later, in that test’s harness) at x = +40 — fully across the whole ring — and Events records Hazard.Entered:Sprinter immediately followed by Hazard.Left:Sprinter, in that order, in the one sweep() call. Neither event is dropped; a damage-on-Entered handler still fires exactly once even though the occupant was never “seen” standing inside.
What rides the wire
Section titled “What rides the wire”| Packet | Schema | Payload |
|---|---|---|
CKHZ_Spawn |
serial NumberU16, defId NumberU8, origin Vector3F32, seed NumberF32, duration NumberF16, anchor Instance? |
21 bytes + at most one Instance reference |
CKHZ_Retire |
serial NumberU16 |
2 bytes |
Fire’s argument order is (serial, defId, origin, seed, duration, anchorInstance) — origin is the anchor’s resolved position at spawn time (full-precision Vector3F32, since a fire ring’s whole lifetime radiates from this one point and any spawn-time error compounds visually across the entire duration), not a live-following value; the client re-resolves a moving anchor itself every frame from the anchor Instance argument. duration rides as NumberF16 and uses 0 as the “no duration” sentinel (Duration or 0 on the way out, if duration > 0 then duration else nil on the way back in) — there’s no wire-level nil for a plain number field, so a manual-Stop()-only hazard is indistinguishable on the wire from one with a literal zero-second duration; don’t define a hazard with Duration = 0 expecting it to expire instantly, because clients will read it as “runs until stopped.” anchor is nil on the wire whenever options.Anchor is a plain Vector3 — only an Instance anchor is ever passed, and an Instance argument costs zero buffer bytes (it rides the RemoteEvent’s own instance array, not the packed buffer). A hazard costs at most ~21 buffer bytes to spawn and 2 more to retire, for its entire life, regardless of how long it runs or how many occupants cross it — occupancy itself never touches the wire at all, because Hazard.Entered/Left are server-only Bus events, not packets.
The client layer: a visual mirror, not a guess
Section titled “The client layer: a visual mirror, not a guess”HazardClient renders CKHZ_Spawn/CKHZ_Retire traffic locally, pooled (Pool, InitialSize = 2 per definition) and seed-deterministic, following ProjectileClient’s conventions:
- The same radius curves, evaluated the same way.
HazardClient’s ownradiusAtresolves eachVisualDefinition’sInner/Outerexactly like the server’s — a plain number or a one-argument(age) -> number. Because the ring VFX ages from the identical function object (when you actually share the module), the visual boundary is never a guess at where the real occupancy boundary sits. - Following a moving anchor. On every
Heartbeat, if the spawn packet carried ananchorInstance and it still has aParent, the visual’s position re-resolves from it (BasePart.PositionorModel:GetPivot().Position) each frame — otherwise it stays parked at the spawn-timeorigin. - A spawn arriving on a still-live serial retires the old entry first — the same reasoning as
ProjectileClient: U16 serials wrap, and skipping this would strand a pooled visual forever. - Safety TTL past
duration. Each entry’sExpiresAtisspawnTime + duration (or 0) + LifetimeSeconds(default 10s) — a lostCKHZ_Retirepacket cannot leak a pool slot forever, mirroringProjectileClient’s ownLifetimeSecondssafety-TTL pattern. A manual-Stop()hazard (wireduration = 0) still gets the fullLifetimeSecondswindow as its only safety net, since the client never independently knows when a duration-less hazard is “supposed” to end. OnRender(visual, seed, age, inner, outer, duration)runs every frame after the visual’sCFrameis set — the one hook that actually receives the aged radii, so a real implementation scales a ring mesh, fades a shader, or drives an emitter’s rate fromouter - inner.OnRetire(visual, seed)runs once, before the pool reclaims the instance — reset any state a plain re-Createwouldn’t clear.
Damage doctrine: Hazards never deals damage
Section titled “Damage doctrine: Hazards never deals damage”Hazards has no Damage, OnHit, or health-touching field anywhere in its Definition. The pattern — same as Zones’ zone auras — is entirely in your Bus handlers: apply an Effect on Hazard.Entered, cleanse it on Hazard.Left. A ticking Effect with TickSeconds does the actual damage-over-time; Hazards only ever answers “who is in the fire right now.”
StreamingEnabled
Section titled “StreamingEnabled”The sweep runs entirely server-side against server-known positions (Character.HumanoidRootPart.Position) and a server-resolved anchor — it never depends on what any client has streamed in, so StreamingEnabled changes nothing about occupancy correctness. HazardClient only ever draws what the wire told it to; there is no client-side geometry query to be affected by streaming either.
-- src/Server/Bootstrap.luaulocal Root = game:GetService("ServerScriptService").ChloeKernelServer
local Effects = require(Root.Effects)local Hazards = require(Root.Hazards)local HazardDefs = require(script.Parent.HazardDefs) -- shared Inner/Outer functions
return function(kernel) local Buffs = Effects.attach(kernel) Buffs:define("Burning", { Duration = 8, -- outlives a brief pass-through; cleansed early by Hazard.Left Category = "Fire", TickSeconds = 1, OnTick = function(session, stacks) local Character = session.Player.Character local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid") if Humanoid then Humanoid:TakeDamage(4 * stacks) end end, })
local Hazard = Hazards.attach(kernel) -- TickRate defaults to 8/s
Hazard:define(1, { Inner = HazardDefs.FireRingInner, -- (age) -> number, shared with the client def Outer = HazardDefs.FireRingOuter, Height = 8, Duration = 5, })
kernel.Bus:subscribe("Hazard.Entered", function(_, handle, player) if handle.Id ~= 1 then return end local Session = kernel:getSession(player) if Session then Buffs:apply(Session, "Burning") end end) kernel.Bus:subscribe("Hazard.Left", function(_, handle, player) if handle.Id ~= 1 then return end local Session = kernel:getSession(player) if Session then Buffs:cleanse(Session, "Fire") end end)
-- Spawn anchored to a moving caster (their HumanoidRootPart), not a fixed point: local Net = kernel:net() Net:defineIntent("CastFireRing", {}, { RateLimit = 1, Handler = function(session) local Character = session.Player.Character local Root = Character and Character:FindFirstChild("HumanoidRootPart") if Root then Hazard:spawn(1, { Anchor = Root, Seed = math.random(1, 1e6) }) end end, })end-- src/Client/Bootstrap.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local HazardClient = require(ReplicatedStorage.ChloeKernel.Net.HazardClient)local HazardDefs = require(ReplicatedStorage.Game.HazardDefs) -- same shared module as the server
return function(kernel) HazardClient.new({ [1] = { Inner = HazardDefs.FireRingInner, -- identical function reference: no visual/logic drift Outer = HazardDefs.FireRingOuter, Create = function() local Ring = Instance.new("Part") Ring.Shape = Enum.PartType.Cylinder Ring.Material = Enum.Material.Neon Ring.Color = Color3.fromRGB(255, 90, 20) Ring.Anchored = true Ring.CanCollide = false Ring.CanQuery = false return Ring -- pooled: reset state here, it will be reused end, OnRender = function(visual, seed, age, inner, outer, duration) visual.Size = Vector3.new(0.2, (outer - inner) * 2, (outer - inner) * 2) end, OnRetire = function(visual, seed) visual.Transparency = 0 -- undo any fade-out before the pool reclaims it end, }, })endHazardDefs (the shared module both sides require) is as small as:
-- ReplicatedStorage.Game.HazardDefsreturn { FireRingInner = function(age) return math.max(0, age * 6 - 4) end, FireRingOuter = function(age) return age * 6 end,}API reference — server
Section titled “API reference — server”Hazards.attach(kernel, options?) options:
| Option | Default | Description |
|---|---|---|
TickRate |
8 |
Occupancy sweeps per second |
GetOccupants |
every Players:GetPlayers() with a Character |
Injectable () -> { any } occupant roster (spec seam; extend to non-player entities by returning anything shaped { Character }) |
PacketFactory |
Net.Packet |
Injectable packet constructor (spec seam) |
Clock |
os.clock |
Injectable clock (spec seam) |
SkipLoop |
false |
Do not schedule the sweep; drive :sweep() yourself |
Definition fields:
| Field | Default | Description |
|---|---|---|
Inner |
0 |
Band inner radius — a number, or (age) -> number |
Outer |
required | Band outer radius — a number, or (age) -> number |
Height |
6 |
Vertical half-extent from the anchor; the slab spans anchor.Y ± Height |
Duration |
nil |
Seconds until auto-retire; nil = manual Stop() only |
SpawnOptions fields:
| Field | Default | Description |
|---|---|---|
Anchor |
required | A fixed Vector3, or an Instance (BasePart/Model) whose live position the band follows |
Seed |
the assigned serial | Per-spawn cosmetic seed forwarded to clients on the spawn packet |
Duration |
the definition’s Duration |
Overrides the definition for this spawn |
| Member | Description |
|---|---|
Hazards.inBand(point, anchor, inner, outer, height) → boolean |
Pure point-in-annulus test |
Hazards.segmentCrossesBand(a, b, anchor, inner, outer, height) → boolean |
Pure segment-vs-annulus anti-tunneling test |
hazard:define(id, definition) |
Register a definition; id is 0..255 (NumberU8 wire id); redefinition errors |
hazard:spawn(id, options) → Handle? |
Validate and spawn; nil on an unknown id, an unresolvable Anchor, or if all 65,535 serials are currently live. Broadcasts CKHZ_Spawn and publishes Hazard.Spawned |
hazard:sweep() |
One occupancy pass; runs on the schedule unless SkipLoop |
hazard:destroy() |
Cancel the loop and retire every live hazard (each still runs the full Left-then-Retired sequence) |
Handle fields:
| Field | Description |
|---|---|
Serial |
The wire serial |
Id |
The definition id |
Anchor |
The original Anchor value passed to spawn |
Origin |
The anchor’s resolved position at spawn time |
Seed |
The cosmetic seed sent to clients |
Stop() |
Retire this hazard now — runs the same Left-then-Retired sequence as a Duration expiry |
API reference — client
Section titled “API reference — client”HazardClient.new(definitions) — definitions maps the same numeric ids to VisualDefinition:
| Field | Default | Description |
|---|---|---|
Inner |
0 |
Match the server definition — a number, or (age) -> number |
Outer |
required | Match the server definition |
LifetimeSeconds |
10 |
Safety TTL added on top of the wire duration against a lost retire packet |
Create |
required | () -> Instance factory; instances are pooled and reused |
OnRender |
nil |
(visual, seed, age, inner, outer, duration?) per-frame hook after the visual’s CFrame is set |
OnRetire |
nil |
(visual, seed) before the pool reclaims the instance |
| Member | Description |
|---|---|
client:destroy() |
Disconnect packets and Heartbeat, release every live visual, destroy every pool |
Bus topics
Section titled “Bus topics”| Topic | Payload | When |
|---|---|---|
Hazard.Spawned |
handle |
A hazard was spawned |
Hazard.Entered |
handle, occupant |
An occupant entered the band (including a through-crossing) |
Hazard.Left |
handle, occupant |
An occupant left the band (including a through-crossing, and every still-inside occupant on retirement) |
Hazard.Retired |
handle |
The hazard ended — duration expiry, Stop(), an unresolvable anchor, or destroy() |
There is no Hazard.EntityEntered/EntityLeft split the way Zones has for tracked non-player entities — every occupant, whatever GetOccupants returns, dispatches through the same two topics.