godot-genre-stealth
Installation
SKILL.md
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
RayCast3Dnodes for massive amounts of vision checks; strictly usePhysicsDirectSpaceState3D.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 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
VisibleOnScreenNotifier3Dto 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 checkis_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_iterationsto 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 - Professional-grade NPC controller with composite vision, sound paths, and alert state logic.
Modular Components
- 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):
- 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
5. 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
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
Weekly Installs
70
Repository
thedivergentai/…c-skillsGitHub Stars
137
First Seen
2 days ago
Security Audits