Skip to content

Hazards

Evaluates dynamic danger areas as annulus bands on a server sweep, with age-driven radii, moving anchors and segment occupancy that a sprint cannot tunnel through, mirrored by pooled client visuals.

Realm
Server + Client
Require
ServerScriptService.ChloeKernelServer.Hazards
Since
v0.7.0
Source
Hazards.luauNet/HazardClient.luau

Hazards turns a dangerous area into a procedural volume: an annulus band (inner radius, outer radius, height) around an anchor, tested as a distance check on a server sweep. There is no part to spawn, resize or destroy. Zones asks who overlaps a designer-placed part; Hazards asks who is within this many studs of this point right now, which is cheap to re-evaluate every sweep and follows an anchor that moves. An expanding fire ring, a poison disc that follows its caster and a closing storm wall are the same test with different Inner and Outer functions. Hazards reports occupancy only; damage comes from Effects applied on its Bus topics.

The other defining decision is how occupancy is tested. When an occupant’s current point is outside the band, the hazard tests the segment they moved along since the last sweep. A thin ring and a sprint can put an occupant outside the band on both samples of a crossing; the segment test still catches it, and the crossing fires Hazard.Entered then Hazard.Left in the same sweep.

Hazards.inBand(point, anchor, inner, outer, height) is the whole geometric test, exported so client code can run the same check:

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: a point exactly Outer studs out, or exactly Height studs above the anchor, is inside. Inner defaults to 0, which makes a filled disc. Height defaults to 6 and is a half-extent, so the band spans anchor.Y - 6 to anchor.Y + 6, a 12-stud slab.

export type Radius = number | ((age: number) -> number)

Inner and Outer are each a number or a function of one argument: age, the seconds since the hazard spawned on the manager’s Clock. An expanding fire ring:

Hazard:define(1, {
Inner = function(age: number): number
return math.max(0, age * 6 - 4) -- the hollow core opens after about 0.7 s
end,
Outer = function(age: number): number
return age * 6 -- the rim grows at 6 studs/s
end,
Height = 8,
Duration = 5,
})

Server and client resolve radii the same way: a number returns unchanged, a missing Inner returns 0, and a function is called with age. Keep the Inner and Outer functions in one shared module that both sides require, the same convention Projectiles uses for its definition ids. The server’s occupancy test and the client’s ring then age from the same curve, and the visual boundary matches the damage boundary.

A radius function that throws retires its own hazard: everyone inside gets Hazard.Left, then Hazard.Retired publishes, and the server logs a warning. The other hazards keep sweeping. A curve that failed every sweep would otherwise keep the timer awake forever.

SpawnOptions.Anchor is a Vector3 (a fixed point: a rune circle, a trap) or an Instance (a caster’s root, a boss). Each sweep resolves it again:

Anchor value Resolves to
Vector3 Itself
Instance with Parent == nil nil, checked before the type
BasePart .Position
Model :GetPivot().Position
Any other Instance nil

A nil anchor retires the hazard on that sweep, before any occupant is tested. The liveness signal is Parent, because a destroyed part still answers Position with its last value. A caster’s root that is destroyed on respawn retires the band on the next sweep instead of leaving it parked at the death spot.

attach creates no timer. The first spawn starts a Scheduler job at kernel.Priority.Low, TickRate times per second (default 8, every 0.125 s), and the sweep that finds no live hazard cancels it. The rate is higher than Zones’ 0.25 s because a band is often thin and moving.

Per live hazard, in order:

  1. Duration. If RetiresAt has passed, retire it and move on; the anchor is not resolved.
  2. Anchor. Resolve it; nil retires.
  3. Radii. Resolve Inner, Outer and Height for this sweep’s age.
  4. Occupants, from GetOccupants() (default: every player whose Character is set). For each one:
    • Resolve the position: the Humanoid’s RootPart, or a HumanoidRootPart child when the model has no Humanoid. A dead humanoid (Health <= 0), a missing character or a missing root yields no position; see the crossing rules below.
    • Take the last recorded position, or the current one on first sight, so a hazard that spawns under someone standing still does not count a crossing.
    • Test inBand for inside now, and only when outside, segmentCrossesBand from the last position to the current one.
    • Compare with the previous sweep and publish.

Hazards has no per-hazard callbacks, only Bus topics, so state updates happen inline with each publish.

Crossing rules: respawns, teleports, deaths and leavers

Section titled “Crossing rules: respawns, teleports, deaths and leavers”

A segment only means something when the occupant actually travelled it. These cases reset or end it:

  • A new character (a respawn) starts a new segment. A respawn across the map never counts as crossing the ring in between.
  • A server teleport clears that player’s last position in every live hazard. Hazards listens for AntiExploit.Forgive, which Movement.teleport (movement monitor) and TeamKit spawns publish before they pivot a character. Game code that moves a character itself should publish it too.
  • A dead character is not an occupant. An occupant who dies inside gets Hazard.Left on the next sweep, and the corpse never re-enters.
  • A session that ends inside a hazard leaves it: Kernel.SessionEnd publishes Hazard.Left for the leaver and drops their state, so a hazard with no Duration does not pin the departed Player.

Duration auto-retire: Left always beats Retired

Section titled “Duration auto-retire: Left always beats Retired”

A hazard retires when its Duration (from SpawnOptions.Duration, else the definition’s) elapses, when its anchor goes away, when a radius function throws, when handle.Stop() runs, or at destroy(). Every path runs the same sequence:

function Hazards._retire(self: any, serial: number)
local Handle = self.Live[serial]
if not Handle then
return
end
self.Live[serial] = nil
self:_sleepIfIdle()
-- 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 inside gets Hazard.Left before Hazard.Retired, and the handle is already out of the live set when either fires. A cleanse-on-Left handler therefore always runs, and it runs before anything keyed on Retired tears down its own state.

A point test has a hole: if a thin ring’s whole width is crossed between two sweeps, the occupant is outside on both samples. At 8 sweeps per second a 30 studs/s sprint covers about 3.75 studs per sweep, enough to clear a 2-stud ring unseen.

Hazards.segmentCrossesBand(a, b, anchor, inner, outer, height) tests the motion segment whenever the point test says outside:

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

The radial reach is exact on the X/Z plane. Nearest is the closest distance from the anchor to the finite segment (the projection parameter is clamped to 0..1). Farthest is the larger endpoint distance, which is the true maximum because distance along a straight segment peaks at an endpoint. The segment touched the annulus exactly when Nearest <= outer and Farthest >= inner.

The height gate is coarse on purpose. It rejects only when the whole segment sits above or below the slab, so a steep segment that crosses the slab outside the ring can pass. For character motion over 0.125 s that over-inclusion is acceptable.

A through-crossing publishes both topics in the same sweep and leaves the occupant outside:

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

A damage-on-Entered handler fires once even though the occupant was never sampled inside.

Packet Schema Payload
CKHZ_Spawn serial NumberU16, defId NumberU8, origin Vector3F32, seed NumberF32, duration NumberF16, anchor Instance? 21 bytes plus at most one Instance reference
CKHZ_Retire serial NumberU16 2 bytes

Byte counts are payload; each server-to-client frame adds its id byte and a varint body length. origin is the anchor’s position at spawn, in full-precision Vector3F32 because the whole visual radiates from it. The client follows a moving anchor itself through the anchor argument, which is sent only for Instance anchors and travels in the remote call’s argument list, not the buffer. duration uses 0 for “no duration”. Occupancy never touches the wire: Hazard.Entered and Hazard.Left are server Bus topics. A hazard costs 23 payload bytes for its whole life.

Serials are NumberU16 from a wrapping counter that skips serials still live, the same allocator as Projectiles, so a wrapped counter never orphans a running hazard. spawn returns nil only when all 65,535 serials are live.

HazardClient renders CKHZ_Spawn and CKHZ_Retire from pooled visuals (Pool, 2 prewarmed per definition), following ProjectileClient’s conventions:

  • The same radius curves, evaluated the same way. When the definitions share the server’s functions, the ring’s boundary is the occupancy boundary.
  • It steps on RunService.PreRender and connects only while a hazard is live or a visual is lingering.
  • A static hazard (a Vector3 anchor) gets its CFrame once at spawn and is never written again. An anchored hazard follows its anchor each frame: BasePart visuals move with one workspace:BulkMoveTo per frame, Model visuals with PivotTo.
  • A spawn on a still-live serial retires the old entry first, so a wrapped serial cannot strand a visual.
  • Each entry expires after duration (or 0) plus LifetimeSeconds (default 10 s), so a lost CKHZ_Retire cannot hold a pool slot forever. A hazard with no duration gets only the LifetimeSeconds window.
  • OnAcquire(visual, seed) runs on every acquire, after the visual is placed and parented. The default is Pool.clearEffects(visual), which clears Trails and particles left from the visual’s last life; a custom hook replaces it.
  • OnRender(visual, seed, age, inner, outer, duration?) runs every frame after the visual moves. It is the one hook that receives the aged radii: scale a ring mesh, drive an emitter’s rate, fade toward the end.
  • On retire, a definition with Linger stops its Trails and emitters first (Pool.quietEffects(visual, false): the body stays visible), then runs OnRetire(visual, seed) through task.spawn, and keeps the visual in place for Linger seconds so particles and a fade in OnRetire can finish. Without Linger, OnRetire runs and the visual returns to the pool at once.
  • A throwing OnRender or OnAcquire retires only its own hazard, with one warning per definition.

Damage doctrine: Hazards never deals damage

Section titled “Damage doctrine: Hazards never deals damage”

A hazard definition has no damage field and no hit callback. Apply an Effect on Hazard.Entered and remove it on Hazard.Left; a ticking effect with TickSeconds does the damage over time. Hazards answers who is in the fire right now.

The sweep runs on the server against server positions and a server-resolved anchor, so StreamingEnabled does not change occupancy. HazardClient draws what the wire says. An anchor Instance that has not streamed in on a client arrives as nil, and that client draws the hazard at its spawn origin.

The radius curves live in one shared module:

src/Shared/Game/Config/HazardShapes.luau
--!strict
-- HazardShapes: hazard ids and radius curves shared by server occupancy and client visuals.
return {
FireRing = {
Id = 1,
Inner = function(age: number): number
return math.max(0, age * 6 - 4)
end,
Outer = function(age: number): number
return age * 6
end,
},
}

The cast intent has no arguments to validate, so it is Open behind a rate limit:

CastFireRing = { Kind = "Intent", Schema = {}, RateLimit = 1, Open = true },
src/Server/Game/Services/FireRingService.luau
--!strict
-- FireRingService: fire rings that burn whoever stands in them.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Hazards = require(ServerScriptService.ChloeKernelServer.Hazards)
local HazardShapes = require(ReplicatedStorage.Game.Config.HazardShapes)
local FireRingService = {
Name = "FireRingService",
-- StatusService owns the game's one Effects instance (see the Effects page)
Dependencies = { "StatusService" },
}
function FireRingService.init(self: any, kernel: any)
self.Kernel = kernel
self.Hazard = Hazards.attach(kernel)
self.Hazard:define(HazardShapes.FireRing.Id, {
Inner = HazardShapes.FireRing.Inner,
Outer = HazardShapes.FireRing.Outer,
Height = 8,
Duration = 5,
})
end
function FireRingService.start(self: any)
local Kernel = self.Kernel
local Buffs = Kernel:getService("StatusService").Buffs
Buffs:define("Burning", {
Duration = 8, -- outlives a brush past the rim; Hazard.Left removes it early
TickSeconds = 1,
OnTick = function(session: any, stacks: number)
local Character = session.Player.Character
local Humanoid = Character and Character:FindFirstChildOfClass("Humanoid")
if Humanoid then
Humanoid:TakeDamage(4 * stacks)
end
end,
})
self.Connections = {
Kernel.Bus:subscribe("Hazard.Entered", function(_topic: string, handle: any, player: Player)
local Session = Kernel:getSession(player)
if handle.Id == HazardShapes.FireRing.Id and Session then
Buffs:apply(Session, "Burning")
end
end),
Kernel.Bus:subscribe("Hazard.Left", function(_topic: string, handle: any, player: Player)
local Session = Kernel:getSession(player)
if handle.Id == HazardShapes.FireRing.Id and Session then
Buffs:remove(Session, "Burning")
end
end),
}
-- The ring follows the caster's root until it expires or the root goes away
self.Net.CastFireRing.on(function(session: any)
local Character = session.Player.Character
local Root = Character and Character:FindFirstChild("HumanoidRootPart")
if Root then
self.Hazard:spawn(HazardShapes.FireRing.Id, { Anchor = Root })
end
end)
end
function FireRingService.stop(self: any)
for _, Connection in self.Connections or {} do
Connection:disconnect()
end
self.Hazard:destroy()
end
return FireRingService

Hazards.attach(kernel, options?) options:

Option Default Description
TickRate 8 Occupancy sweeps per second while a hazard is live
GetOccupants players with a Character () -> { any } roster; anything shaped { Character } works
PacketFactory Net.Packet Packet constructor (spec seam)
Clock os.clock Time source for ages and durations (spec seam)
SkipLoop false Never schedule the sweep; call :sweep() yourself

Definition fields:

Field Default Description
Inner 0 Inner radius: a number or (age) -> number
Outer required Outer radius: a number or (age) -> number
Height 6 Vertical half-extent; the slab spans the anchor’s Y plus and minus Height
Duration nil Seconds until auto-retire; nil runs until Stop()

SpawnOptions fields:

Field Default Description
Anchor required A fixed Vector3, or a BasePart or Model whose live position the band follows
Seed the serial Cosmetic seed sent to clients
Duration the definition’s Overrides the definition for this spawn
Member Description
Hazards.inBand(point, anchor, inner, outer, height) -> boolean Pure point-in-annulus test, inclusive
Hazards.segmentCrossesBand(a, b, anchor, inner, outer, height) -> boolean Pure segment-against-annulus test
hazards:define(id, definition) Registers a definition. id is an integer 0 to 255; Outer is required; a second define of the same id errors
hazards:spawn(id, options) -> Handle? Spawns a hazard, broadcasts CKHZ_Spawn and publishes Hazard.Spawned. nil for an unknown id, an anchor that does not resolve, every serial live, or after destroy()
hazards:sweep() One occupancy pass; scheduled automatically unless SkipLoop
hazards:destroy() Cancels the sweep, unsubscribes from the Bus and retires every live hazard with the full Left-then-Retired sequence

Handle fields:

Member Description
handle.Serial The wire serial
handle.Id The definition id
handle.Anchor The Anchor passed to spawn
handle.Origin The anchor’s position at spawn
handle.Seed The cosmetic seed sent to clients
handle.Stop() Retires the hazard now with the Left-then-Retired sequence

HazardClient.new(definitions), where definitions maps the server’s ids to VisualDefinition:

Field Default Description
Inner 0 Match the server definition
Outer required Match the server definition
LifetimeSeconds 10 Safety TTL on top of the wire duration
Create required () -> Instance: a BasePart, or a Model moved by PivotTo. Runs only when the pool grows
OnAcquire Pool.clearEffects (visual, seed) on every acquire, after placement; replaces the default
OnRender nil (visual, seed, age, inner, outer, duration?) every frame after the visual moves
OnRetire nil (visual, seed) through task.spawn when the hazard retires, after emitters stop
Linger 0 Seconds a retired visual stays in place with Trails and emitters stopped
Member Description
client:destroy() Disconnects the packets and the stepper, releases every live visual and destroys every pool, lingering visuals included
Topic Payload When
Hazard.Spawned handle A hazard spawned
Hazard.Entered handle, occupant An occupant entered the band, including a through-crossing
Hazard.Left handle, occupant An occupant left: a through-crossing, death, a session end, or retirement
Hazard.Retired handle The hazard ended: duration, Stop(), a lost anchor, a throwing radius function, or destroy()

Consumed: AntiExploit.Forgive (restarts the player’s segment) and Kernel.SessionEnd (the leaver leaves). There is no entity split like Zones has; whatever GetOccupants returns publishes through the same two topics.

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