"Expert blueprint for stealth games (Splinter Cell, Hitman, Dishonored, Thief) covering AI detection systems, vision cones, sound propagation, alert states, light/shadow mechanics, and systemic design. Use when building stealth-action, tactical infiltration, or immersive sim games requiring enemy awareness systems. Keywords vision cone, detection, alert state, sound propagation, light level, systemic AI, gradual detection."
Resources
1Install
npx skillscat add thedivergentai/gd-agentic-skills/godot-genre-stealth Install via the SkillsCat registry.
The skill delivers a modular AI framework that models vision cones, sound propagation, alert states, and light/shadow detection for stealth games. It solves the issue of unrealistic or binary detection by providing gradual, geometry‑aware awareness, and is appropriate for stealth‑action or immersive simulation projects requiring detailed enemy awareness systems.
Genre: Stealth
Player choice, systemic AI, and clear communication define stealth games.
Available Scripts
stealth_ai_controller.gd
Expert AI controller with graduated detection, sound response, and alert state management.
Core Loop
Observe → Plan → Execute → Adapt → Complete
NEVER Do in Stealth Games
- NEVER use instant binary detection — Gradual 0-100% detection with visual feedback (filling meter). Binary "seen/not seen" removes player agency and feels unfair.
- NEVER make guards see through walls — Raycast-based vision with collision masks.
has_line_of_sight()must check geometry. Wallhacks destroy stealth integrity. - NEVER use simple distance checks for sound — Sound propagates along
NavigationServer3Dpaths, NOT straight-line distance. Through-wall hearing breaks immersion.
-NEVER make combat as viable as stealth — If guns are easier than sneaking, players ignore stealth. Combat should be risky (outnumbered, limited ammo, loud alerts). - NEVER hide detection reasons from player — Show WHY detected (light level high, made noise, in vision cone). "Gotcha" deaths frustrate, don't teach.
- NEVER use single sample point for player visibility — Sample multiple body parts (head, torso, feet). Hiding behind low cover should hide torso but expose head.
- NEVER forget peripheral vision — Humans have ~180° peripheral (less effective) + 60° focused vision. Single cone = unrealistic. Use composite shapes (Splinter Cell method).
Design Principles
From industry experts (Splinter Cell, Dishonored, Hitman developers):
- Player Choice: Multiple valid approaches to every scenario
- Systemic Design: Rules-based AI that players can learn and exploit
- Clear Communication: Player always understands game state and threats
- Fair Detection: No "gotcha" moments - threats visible before dangerous
AI Detection System
Vision Cone Implementation
Based on Splinter Cell Blacklist GDC talk - realistic vision uses composite shapes:
class_name EnemyVision
extends Node3D
@export var forward_vision_range := 20.0 # Main vision cone
@export var peripheral_range := 10.0 # Side vision
@export var forward_fov := 60.0 # Degrees
@export var peripheral_fov := 120.0 # Degrees
@export var detection_speed := 1.0 # How fast detection builds
var detection_level := 0.0 # 0-100
var target: Node3D = null
func _physics_process(delta: float) -> void:
var player := get_player_if_visible()
if player:
# Detection rate varies by:
# - Distance (closer = faster)
# - Lighting on player
# - Player movement (moving = more visible)
# - In peripheral vs direct vision
var rate := calculate_detection_rate(player)
detection_level = min(100, detection_level + rate * delta)
else:
detection_level = max(0, detection_level - detection_speed * 0.5 * delta)
func get_player_if_visible() -> Player:
var player := get_tree().get_first_node_in_group("player")
if not player:
return null
var to_player := player.global_position - global_position
var distance := to_player.length()
var angle := rad_to_deg(global_basis.z.angle_to(-to_player.normalized()))
# Check forward cone
if angle < forward_fov / 2.0 and distance < forward_vision_range:
if has_line_of_sight(player):
return player
# Check peripheral (less effective)
elif angle < peripheral_fov / 2.0 and distance < peripheral_range:
if has_line_of_sight(player):
return player
return null
func calculate_detection_rate(player: Player) -> float:
var distance := global_position.distance_to(player.global_position)
var distance_factor := 1.0 - (distance / forward_vision_range)
var light_factor := player.get_light_level() # 0.0 = dark, 1.0 = lit
var movement_factor := 1.0 if player.velocity.length() > 0.5 else 0.3
return detection_speed * distance_factor * light_factor * movement_factor * 50.0Sound Detection System
Based on Thief/Hitman implementation - sounds propagate along navigation paths:
class_name SoundPropagation
extends Node
# Sound travels through connected navigation points, not through walls
func propagate_sound(origin: Vector3, loudness: float, sound_type: String) -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
var path := NavigationServer3D.map_get_path(
get_world_3d().navigation_map,
origin,
enemy.global_position,
true
)
if path.is_empty():
continue # No path = sound blocked
var path_distance := calculate_path_length(path)
var heard_loudness := loudness - (path_distance * 0.5) # Falloff
if heard_loudness > enemy.hearing_threshold:
enemy.hear_sound(origin, sound_type, heard_loudness)
func calculate_path_length(path: PackedVector3Array) -> float:
var length := 0.0
for i in range(1, path.size()):
length += path[i].distance_to(path[i - 1])
return lengthPlayer Light Level
class_name LightDetector
extends Node3D
@export var sample_points: Array[Marker3D] # Multiple points on player body
func get_light_level() -> float:
var total := 0.0
var space := get_world_3d().direct_space_state
for point in sample_points:
for light in get_tree().get_nodes_in_group("lights"):
var dir := light.global_position - point.global_position
var query := PhysicsRayQueryParameters3D.create(
point.global_position,
light.global_position
)
var result := space.intersect_ray(query)
if result.is_empty(): # Not blocked
total += light.light_energy / dir.length_squared()
return clamp(total / sample_points.size(), 0.0, 1.0)AI Alert States
Three-phase system (industry standard):
enum AlertState { IDLE, SUSPICIOUS, ALERTED, COMBAT }
class_name EnemyAI
extends CharacterBody3D
var alert_state := AlertState.IDLE
var suspicion_point: Vector3
var search_timer := 0.0
signal alert_state_changed(new_state: AlertState)
func transition_to(new_state: AlertState) -> void:
alert_state = new_state
alert_state_changed.emit(new_state)
match new_state:
AlertState.SUSPICIOUS:
play_animation("suspicious")
speak_dialogue("what_was_that")
AlertState.ALERTED:
speak_dialogue("who_goes_there")
# Other guards in range hear and become suspicious
alert_nearby_guards()
AlertState.COMBAT:
speak_dialogue("intruder")
trigger_alarm()Visual Feedback (Critical!)
class_name AlertIndicator
extends Node3D
@export var idle_icon: Texture2D
@export var suspicious_icon: Texture2D # "?"
@export var alerted_icon: Texture2D # "!"
@export var detection_meter: ProgressBar # Shows filling detection
func update_indicator(state: AlertState, detection: float) -> void:
detection_meter.value = detection
match state:
AlertState.IDLE:
icon.texture = idle_icon
detection_meter.visible = false
AlertState.SUSPICIOUS:
icon.texture = suspicious_icon
detection_meter.visible = true
AlertState.ALERTED:
icon.texture = alerted_icon
detection_meter.visible = falsePlayer Abilities
Five categories of stealth tools (per Mark Brown's analysis):
1. Movement Alteration
# Crouch, crawl, run (noisy vs quiet)
func calculate_noise_level() -> float:
if is_crouching:
return 0.2
elif is_running:
return 1.0
else:
return 0.52. Information Gathering
# Peek, scout, mark enemies
func activate_detective_vision() -> void:
for enemy in get_tree().get_nodes_in_group("enemies"):
enemy.show_outline()
enemy.show_vision_cone()3. AI Manipulation
# Throw distractions
func throw_distraction(target_position: Vector3) -> void:
var rock := distraction_scene.instantiate()
rock.global_position = target_position
add_child(rock)
SoundPropagation.propagate_sound(target_position, 30.0, "impact")4. Space Control
# Shoot out lights, create hiding spots
func shoot_light(light: Light3D) -> void:
light.visible = false
# Update light level for area5. Enemy Elimination
func perform_takedown(enemy: EnemyAI, lethal: bool) -> void:
if enemy.alert_state == AlertState.COMBAT:
return # Can't stealth kill alert enemy
if lethal:
enemy.die()
else:
enemy.knockout()
# Body becomes interactable
spawn_body(enemy)Level Design
Outpost Design (Open Areas)
[Safe perimeter for observation]
|
[Sparse guards at edges - isolatable]
|
[Dense center with objective]
|
[Multiple entry points/routes]Limited Encounter Design (Corridors)
- Enemies visible 8+ meters before engagement
- Multiple paths through
- Cover objects and hiding spots
- Emergency escape routes
UI Communication
Based on Thief's "light gem" innovation:
class_name StealthHUD
extends Control
@onready var visibility_meter: TextureProgressBar
@onready var sound_meter: TextureProgressBar
@onready var minimap: Control
func _process(_delta: float) -> void:
visibility_meter.value = player.get_light_level() * 100
sound_meter.value = player.current_noise_level * 100Common Pitfalls
| Pitfall | Solution |
|---|---|
| Instant detection | Use gradual detection with clear feedback |
| Guards see through walls | Raycast-based vision with proper collision |
| Unfair patrol patterns | Make patterns learnable, with tells |
| Two games (stealth + combat) | Either commit to stealth or make combat risky |
| Unclear detection | Always show WHY player was detected |
Godot-Specific Tips
- Raycasts for vision: Use
PhysicsRayQueryParameters3Dwith collision masks - NavigationAgent3D: For patrol routes and pathfinding
- Area3D: For sound propagation zones and trigger areas
- AnimationTree: Blend between alert state animations
Reference
- Master Skill: godot-master