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.
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, 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 <= outerendBoth 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.
Radii as functions of age
Section titled “Radii as functions of age”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.
Anchors: a point, or a live Instance
Section titled “Anchors: a point, or a live Instance”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.
The sweep, in order
Section titled “The sweep, in order”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:
- Duration. If
RetiresAthas passed, retire it and move on; the anchor is not resolved. - Anchor. Resolve it;
nilretires. - Radii. Resolve
Inner,OuterandHeightfor this sweep’s age. - Occupants, from
GetOccupants()(default: every player whoseCharacteris set). For each one:- Resolve the position: the
Humanoid’sRootPart, or aHumanoidRootPartchild when the model has noHumanoid. 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
inBandfor inside now, and only when outside,segmentCrossesBandfrom the last position to the current one. - Compare with the previous sweep and publish.
- Resolve the position: the
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, whichMovement.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.Lefton the next sweep, and the corpse never re-enters. - A session that ends inside a hazard leaves it:
Kernel.SessionEndpublishesHazard.Leftfor the leaver and drops their state, so a hazard with noDurationdoes not pin the departedPlayer.
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)endEvery 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.
The anti-tunneling mechanic
Section titled “The anti-tunneling mechanic”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 >= innerendThe 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)endA damage-on-Entered handler fires once even though the occupant was never sampled 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 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.
The client layer
Section titled “The client layer”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.PreRenderand connects only while a hazard is live or a visual is lingering. - A static hazard (a
Vector3anchor) gets its CFrame once at spawn and is never written again. An anchored hazard follows its anchor each frame: BasePart visuals move with oneworkspace:BulkMoveToper frame, Model visuals withPivotTo. - 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) plusLifetimeSeconds(default 10 s), so a lostCKHZ_Retirecannot hold a pool slot forever. A hazard with no duration gets only theLifetimeSecondswindow. OnAcquire(visual, seed)runs on every acquire, after the visual is placed and parented. The default isPool.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
Lingerstops its Trails and emitters first (Pool.quietEffects(visual, false): the body stays visible), then runsOnRetire(visual, seed)throughtask.spawn, and keeps the visual in place forLingerseconds so particles and a fade inOnRetirecan finish. WithoutLinger,OnRetireruns and the visual returns to the pool at once. - A throwing
OnRenderorOnAcquireretires 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.
StreamingEnabled
Section titled “StreamingEnabled”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:
--!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 },--!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--!strict-- HazardController: draws every server hazard from pooled ring parts.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local HazardClient = require(ReplicatedStorage.ChloeKernel.Net.HazardClient)local HazardShapes = require(ReplicatedStorage.Game.Config.HazardShapes)
local HazardController = { Name = "HazardController",}
local function makeRing(): Instance local Ring = Instance.new("Part") Ring.Shape = Enum.PartType.Cylinder Ring.Material = Enum.Material.Neon Ring.Color = Color3.fromRGB(255, 90, 20) Ring.Transparency = 0.4 Ring.Anchored = true Ring.CanCollide = false Ring.CanQuery = false Ring.CanTouch = false return Ringend
function HazardController.init(self: any, kernel: any) self.Kernel = kernelend
function HazardController.start(self: any) self.Visuals = HazardClient.new({ [HazardShapes.FireRing.Id] = { Inner = HazardShapes.FireRing.Inner, -- the server's own functions Outer = HazardShapes.FireRing.Outer, Create = makeRing, OnRender = function(visual: BasePart, _seed: number, _age: number, _inner: number, outer: number) -- A cylinder's axis is X: lay it flat and size it to the rim visual.Size = Vector3.new(0.2, outer * 2, outer * 2) visual.CFrame = CFrame.new(visual.Position) * CFrame.Angles(0, 0, math.rad(90)) end, }, })end
function HazardController.stop(self: any) if self.Visuals then self.Visuals:destroy() endend
return HazardControllerAPI reference: server
Section titled “API reference: server”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 |
API reference: client
Section titled “API reference: client”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 |
Bus topics
Section titled “Bus topics”| 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.