Skip to content

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.

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 <= outer
end

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

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.

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

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

The 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 = 0
for _ = 1, 65535 do
local Candidate = self.NextSerial % 65535 + 1
self.NextSerial = Candidate
if self.Live[Candidate] == nil then
Serial = Candidate
break
end
end
if Serial == 0 then
return nil
end

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

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:

  1. Duration check first. If RetiresAt has passed, retire and move to the next hazard — the anchor is never even resolved for an already-expired hazard.
  2. Anchor liveness. Resolve the anchor to a Vector3; a nil result retires (see above).
  3. Age the radii. Inner/Outer/Height resolve for this sweep’s age.
  4. Per occupant, using the roster from GetOccupants() (default: every Players:GetPlayers() with a non-nil Character — Hazards, unlike Zones, does not track arbitrary NPCs/props out of the box; supply a custom GetOccupants returning 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 inBand for “inside now,” and — only when not already insidesegmentCrossesBand from last position to current position.
    • Diff against last sweep’s membership and dispatch (see below).

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

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

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 >= inner
end

Read it as two exact 2D (X/Z-plane) tests plus one coarse height test:

  • Radial reach is exact. Nearest is the true closest distance from the anchor to the finite segment (project the anchor onto the segment’s line, clamp the parameter T to [0, 1] so the closest point can’t fall outside the actual travelled path, then measure). Farthest is 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 >= inner is true exactly when the straight path swept through the annulus at some point between a and b.
  • 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)
end

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

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 own radiusAt resolves each VisualDefinition’s Inner/Outer exactly 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 an anchor Instance and it still has a Parent, the visual’s position re-resolves from it (BasePart.Position or Model:GetPivot().Position) each frame — otherwise it stays parked at the spawn-time origin.
  • 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’s ExpiresAt is spawnTime + duration (or 0) + LifetimeSeconds (default 10s) — a lost CKHZ_Retire packet cannot leak a pool slot forever, mirroring ProjectileClient’s own LifetimeSeconds safety-TTL pattern. A manual-Stop() hazard (wire duration = 0) still gets the full LifetimeSeconds window 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’s CFrame is 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 from outer - inner.
  • OnRetire(visual, seed) runs once, before the pool reclaims the instance — reset any state a plain re-Create wouldn’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.”

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.luau
local 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

HazardDefs (the shared module both sides require) is as small as:

-- ReplicatedStorage.Game.HazardDefs
return {
FireRingInner = function(age) return math.max(0, age * 6 - 4) end,
FireRingOuter = function(age) return age * 6 end,
}

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

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