- 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
- NavigationServer3D
- paths, 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.0 Sound 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 length Player 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 = false Player 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.5 2. 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 area
- 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
- *
- 100
- Common 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
- PhysicsRayQueryParameters3D
- with 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