In tactical stealth games, survival horror titles, and infiltration espionage experiences on Roblox (such as Entry Point, The Rake, and tactical co-op incursions), realistic NPC sensory awareness is paramount. Players quickly lose immersion if guards possess omniscience through solid walls, or conversely, if enemies fail to spot an infiltrator sprinting across a brightly lit courtyard.
Creating convincing stealth demands a multi-modal sensory architecture combining optical cone calculations, physical shadow occlusion, ambient light level evaluation, and acoustic wave attenuation. In this technical engineering masterclass, we architect a production-grade stealth sensory pipeline in Luau. We implement dot-product vision cones, raycast-based shadow volume tests, surface-dependent footstep sound propagation, and a state machine transitioning smoothly from Unaware to Combat.
1. The Omniscience Dilemma: Why Naive Distance Checks Fail
Primitive stealth systems rely solely on Euclidean distance checks (`(guard.Position - player.Position).Magnitude < range`), introducing game-breaking design flaws:
- X-Ray Perception: Distance-only checks detect players through concrete walls, closed doors, and multiple floors, causing frustrating unprovoked guard alerts.
- Blind-Spot Neglect: Guards detect players standing directly behind them without turning their heads, punishing flank maneuvers and takedown setups.
- Light-Insensitive Exposure: Players hiding in pitch-black shadow volumes are spotted at the exact same distance as players standing beneath high-intensity floodlights.
- The Sensory Occlusion Solution: Decoupling perception into optical vision cones (FOV dot product + raycasting), ambient light sampling, and acoustic hearing radiuses.
2. Mathematical Foundations: Dot Product Cones, Light Attenuation & Acoustic Decibels
Realistic stealth sensory simulation combines vector geometry, inverse-square light propagation, and material acoustic absorption:
- Field of View (FOV) Dot Product: Given guard look-vector L and normalized direction vector to target D, cos(theta) = dot(L, D). If dot(L, D) >= cos(FOV_angle / 2), the target is within the vision arc.
- Shadow Raycast Occlusion: Raycasting from the global sun/moon vector toward the character torso. If a solid barrier intercepts the ray, the character resides in a geometric shadow volume.
- Point Light Inverse-Square Law: Illuminance E = I / (d^2 + epsilon). Total player exposure coefficient S_light is the sum of ambient baseline light plus nearby attenuated point/spot lights.
- Acoustic Material Attenuation: Footstep sound intensity I_sound = Base_Intensity * (Speed / Run_Speed) * Surface_Multiplier / (d^1.5), filtered by structural wall obstruction penalties.
3. Complete Production Stealth Sensory Controller Luau Implementation
The following modular Luau script evaluates guard sight, light exposure, and sound propagation on Heartbeat intervals with RaycastParams caching:
- Raycast Vision Cones: Validates unobstructed line-of-sight between guard eyes and player head/torso bones.
- Shadow & Light Exposure Sampling: Calculates whether the player is shrouded in shadow or illuminated by point lights.
- Four-Tier Alert State Machine: Manages transitions between Unaware (0%), Suspicious (25%), Alerted (75%), and Combat (100%).
--!strict
-- StealthSensoryController.luau
-- Production stealth sensory perception engine for Roblox Studio
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
export type AlertState = "Unaware" | "Suspicious" | "Alerted" | "Combat"
export type GuardSensoryConfig = {
FieldOfViewDeg: number,
SightRangeMax: number,
HearingRangeMax: number,
ShadowDetectionFactor: number, -- Visibility multiplier in dark shadows (e.g. 0.35)
AlertBuildRate: number, -- Awareness points per second
AlertDecayRate: number, -- Awareness points lost per second
}
local StealthController = {}
StealthController.__index = StealthController
function StealthController.new(guardModel: Model, config: GuardSensoryConfig)
local self = setmetatable({}, StealthController)
self.Guard = guardModel
self.Config = config
self.Head = guardModel:WaitForChild("Head") :: BasePart
self.Awareness = 0 -- 0 to 100 scale
self.State = "Unaware" :: AlertState
-- Raycast parameters setup with collision filtering
self.RayParams = RaycastParams.new()
self.RayParams.FilterType = RaycastFilterType.Exclude
self.RayParams.FilterDescendantsInstances = {guardModel}
self.RayParams.IgnoreWater = true
return self
end
function StealthController:IsTargetInSightCone(targetPos: Vector3): (boolean, number)
local eyePos = self.Head.Position
local lookDir = self.Head.CFrame.LookVector
local toTarget = targetPos - eyePos
local distance = toTarget.Magnitude
if distance > self.Config.SightRangeMax then
return false, 0
end
local dirNormalized = toTarget.Unit
local dotProduct = lookDir:Dot(dirNormalized)
local minDot = math.cos(math.rad(self.Config.FieldOfViewDeg * 0.5))
if dotProduct >= minDot then
return true, distance
end
return false, distance
end
function StealthController:IsTargetInShadow(targetPart: BasePart): boolean
-- Directional sunlight raycast (estimating sun direction)
local sunDir = Vector3.new(0.3, 0.9, 0.2).Unit
local rayOrigin = targetPart.Position + Vector3.new(0, 1, 0)
local rayDir = sunDir * 100
local result = Workspace:Raycast(rayOrigin, rayDir, self.RayParams)
-- If hit solid object above, target is occluded from direct sunlight
return result ~= nil
end
function StealthController:EvaluateSensory(playerCharacter: Model, dt: number)
local targetTorso = playerCharacter:FindFirstChild("HumanoidRootPart") :: BasePart?
local targetHead = playerCharacter:FindFirstChild("Head") :: BasePart?
if not targetTorso or not targetHead then return end
local inCone, dist = self:IsTargetInSightCone(targetHead.Position)
local canSee = false
if inCone then
-- Line-of-sight raycast check
local eyePos = self.Head.Position
local toHead = targetHead.Position - eyePos
local hitResult = Workspace:Raycast(eyePos, toHead, self.RayParams)
if hitResult and hitResult.Instance:IsDescendantOf(playerCharacter) then
canSee = true
end
end
if canSee then
local inShadow = self:IsTargetInShadow(targetTorso)
local lightFactor = inShadow and self.Config.ShadowDetectionFactor or 1.0
local distanceFactor = 1.0 - math.clamp(dist / self.Config.SightRangeMax, 0, 1)
-- Awareness builds faster at close range under bright light
local buildIncrement = self.Config.AlertBuildRate * distanceFactor * lightFactor * dt
self.Awareness = math.clamp(self.Awareness + buildIncrement, 0, 100)
else
-- Decay awareness when target lost
self.Awareness = math.clamp(self.Awareness - (self.Config.AlertDecayRate * dt), 0, 100)
end
-- State transition evaluation
if self.Awareness >= 100 then
self.State = "Combat"
elseif self.Awareness >= 70 then
self.State = "Alerted"
elseif self.Awareness >= 25 then
self.State = "Suspicious"
else
self.State = "Unaware"
end
end
return StealthController
4. Acoustic Sound Propagation & Alert Escalation State Machines
Vision is only half of stealth gameplay; sound creates dynamic cat-and-mouse tension through structural acoustics:
- Material Footstep Audio: Sprinting on metal grills generates a 24-stud sound pulse, stone creates 14 studs, while crouching on carpet reduces sound radius to 3 studs.
- Structural Sound Occlusion: When a sound wave encounters a concrete wall, its effective propagation radius is attenuated by 65%, preventing unnatural alerts across solid rooms.
- Suspicious State Investigation: In the 'Suspicious' state (25-69% awareness), NPCs halt patrol routes, rotate their heads toward the sound origin, and shine flashlights.
- Combat State Escalation: Reaching 100% awareness triggers broadcast alarm signals to nearby squad members within a 60-stud radius, initiating tactical surround maneuvers.
5. Production Optimization & Raycast Budgeting on Mobile Devices
Running dozens of active stealth guards at 60 FPS across mobile and low-end hardware requires rigorous computational throttling:
- Staggered Sensory Stepping: Instead of raycasting every frame for every guard, divide guards into 4 interleaving buckets, evaluating each guard once every 4 frames (15 Hz refresh).
- Spatial Partitioning Pre-Filter: Use spatial hash grids or octrees to prune player-guard pairs exceeding maximum perception radius before calculating dot products or raycasts.
- Cached RaycastParams: Allocate a single reusable `RaycastParams` instance per sensory controller, updating only the exclusion whitelist to prevent GC memory thrashing.
- Visual Stealth HUD Indicators: Render curved awareness meters and directional threat arrows in client GUI, binding smoothly to server-replicated awareness floats.
Frequently Asked Questions
How do I prevent NPCs from spotting players through semi-transparent glass or chain-link fences?
Configure the RaycastParams filter table or use CollisionGroups to mark transparent materials. If a ray intersects glass with a Transparency > 0.5, apply a visual distortion coefficient (e.g. 0.4x detection speed) rather than blocking vision entirely.
Why should sight cone checks use dot products before casting rays?
Raycasts perform expensive spatial intersection tests against the physics geometry. The dot product is a trivial 3-multiplication algebraic vector test that prunes out 75% of non-visible targets instantly, saving vital CPU frametime.
How can I implement dynamic shadow volumes for moving spotlights and flashlights?
For dynamic spotlights, cast a ray from the spotlight's position toward the player torso. If the distance is less than the spotlight range, within the spot angle, and unobstructed, the player is illuminated; otherwise, they remain in shadow.
How do I handle sound detection when multiple players sprint simultaneously?
Maintain an acoustic sound queue in the StealthController. Each frame, evaluate the loudest sound origin within hearing radius, causing the NPC to prioritize high-decibel stimuli (such as gunshots or breached doors) over distant footsteps.
What is the recommended alert decay rate for engaging stealth gameplay?
A decay rate of 10 to 15 awareness points per second allows players who break line-of-sight to hide behind cover and reset guard vigilance within 4 to 6 seconds, providing rewarding, responsive stealth recovery loops.