--- name: godot-genre-stealth description: "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." --- # Genre: Stealth Player choice, systemic AI, and clear communication define stealth games. ## NEVER Do (Expert Anti-Patterns) ### Detection & Awareness - NEVER use binary "Seen/Not Seen" detection; strictly use a **Gradual Detection Meter** (0-100%) that builds based on distance, light level, and speed. - NEVER use standard `RayCast3D` nodes for massive amounts of vision checks; strictly use **`PhysicsDirectSpaceState3D.intersect_ray()`** to query the PhysicsServer instantly and nodelessly. - NEVER allow AI to see through solid geometry; strictly use raycasts between AI eyes and player sample points (Head/Torso/Feet). - NEVER use a single sample point for visibility; strictly sample **at least 3 points** (Head, Torso, Feet) to prevent detection bugs when partially in cover. - NEVER use static "Guard Paths"; strictly implement **Dynamic Investigating** where guards leave their route to check on suspicious sounds/activities. - NEVER trigger "Detection" immediately upon line-of-sight; strictly use a **Detection Meter** with a decay rate to provide a "forgiveness window" for the player to recover. - NEVER assume a random navmesh point is safe; strictly verify cover points by **Raycasting toward the Threat** to ensure geometry successfully breaks the line of sight. - NEVER forget to pass the guard's own **RID** into the raycast exclude array; if omitted, the ray will hit the guard's own body, causing false blocking. - NEVER run complex AI detection for off-screen guards; strictly use `VisibleOnScreenNotifier3D` to pause heavy logic for distant enemies. ### Systemic & World Logic - NEVER use a simple `distance_to()` check for hearing; strictly calculate sound travel along the **Navigation Path** to determine if a wall blocks noise. - NEVER make combat as viable as stealth; strictly ensure "going loud" triggers intense reinforcements or high-lethality states to preserve the stealth loop. - NEVER hide the "Why" of detection; strictly provide immediate feedback via **UI icons (?, !)** or audio barks ("What was that?"). - NEVER ignore the return value of `intersect_ray()`; strictly check `is_empty()` first to prevent runtime crashes. - NEVER assume a raycast won't hit the guard itself; strictly **exclude the guard's RID** from Query Parameters. ### Optimization & Performance - NEVER tightly couple AI to player scripts; strictly use **duck-typing** (e.g., `if body.has_method("get_detected")`) so guards can spot decoys or dead bodies without brittle dependencies. - NEVER maintain hardcoded arrays to trigger base-wide alarms; strictly add guards to a **"guards" group** and use `get_tree().call_group()` for dynamic notification. - NEVER use standard Strings for AI state; strictly use `StringName` (&"alert") for O(1) pointer-level comparisons in high-frequency loops. - NEVER bake massive NavigationMeshes synchronously; strictly use `use_async_iterations` to prevent main thread stalls during runtime bakes. - NEVER rely on `Node.find_child()` during gameplay; strictly use **Groups** or exported references for O(1) player tracking. - NEVER leave CollisionShapes enabled on incapacitated bodies; strictly disable them or move them to a "corpse" layer to prevent pathing interference. --- ## 🛠 Expert Components (scripts/) ### Original Expert Patterns - [stealth_ai_controller.gd](scripts/stealth_ai_controller.gd) - Professional-grade NPC controller with composite vision, sound paths, and alert state logic. ### Modular Components - [stealth_patterns.gd](scripts/stealth_patterns.gd) - Collection of patterns for PhysicsServer raycasting, noise bus routing, and avoidance masking. --- ## Design Principles From industry experts (Splinter Cell, Dishonored, Hitman developers): 1. **Player Choice**: Multiple valid approaches to every scenario 2. **Systemic Design**: Rules-based AI that players can learn and exploit 3. **Clear Communication**: Player always understands game state and threats 4. **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**: ```gdscript 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: ```gdscript 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 ```gdscript 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): ```gdscript 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!) ```gdscript 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # 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 ```gdscript # Shoot out lights, create hiding spots func shoot_light(light: Light3D) -> void: light.visible = false # Update light level for area ``` ### 5. Enemy Elimination ```gdscript 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: ```gdscript 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 ``` ### 4. Reaction-Delay Window (Meter) Decoupled detection tracking with a forgiveness period to prevent instant "gotcha" moments. ```gdscript # detection_meter.gd func process_vision(is_seeing: bool, delta: float): if is_seeing: current_awareness += delta if current_awareness >= threshold: player_detected.emit() else: # Gradually decay awareness when out of sight current_awareness = max(0, current_awareness - delta * 0.5) ``` ### 5. Cover-Point Finder (Raycast) Find and verify valid cover points using Navigation and Physics queries. ```gdscript # cover_finder.gd func find_cover(threat_pos: Vector3): var map = get_world_3d().get_navigation_map() for i in 10: # Get a random point on the navmesh var p = NavigationServer3D.map_get_random_point(map, 1, false) # Verify LOS is broken from the point to the threat var query = PhysicsRayQueryParameters3D.create(p + Vector3.UP, threat_pos + Vector3.UP) var hit = get_world_3d().direct_space_state.intersect_ray(query) if hit: return p # Hit environmental geometry, point is valid cover return global_position ``` ### 6. Detection Cone Shader (Post-Process) Highlight detection areas using a screen-space shader for clear player feedback. ```glsl // vision.gdshader shader_type canvas_item; uniform sampler2D screen : hint_screen_texture; uniform vec2 enemy_pos; // Normalized screen coordinates (0.0-1.0) void fragment() { vec4 base = texture(screen, SCREEN_UV); float dist = distance(SCREEN_UV, enemy_pos); if (dist < 0.2) { // Tint detected area red COLOR = mix(base, vec4(1, 0, 0, 1), 0.3); } else { COLOR = base; } } ``` ``` --- ## 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 1. **Raycasts for vision**: Use `PhysicsRayQueryParameters3D` with collision masks 2. **NavigationAgent3D**: For patrol routes and pathfinding 3. **Area3D**: For sound propagation zones and trigger areas 4. **AnimationTree**: Blend between alert state animations ## Reference - Master Skill: [godot-master](../godot-master/SKILL.md)