Overview
Tested with: Godot 4.3+
In stealth games, you need a mechanism where the player is detected only when entering an enemy's field of view. Think of games like Metal Gear Solid or Hitman -- enemies spot you when you're in front of them, but sneaking up from behind goes unnoticed. This forward-facing vision is called FOV (Field of View).
Simple distance checks alone won't cut it -- the player would be spotted even when directly behind an enemy. You need to consider three factors: distance from the enemy, angle relative to the enemy's facing direction, and whether walls block the line of sight. Only when all three checks pass should detection occur.
This article explains how to build a vision system using a 3-step approach: Area3D (detection range) + dot product (angle check) + RayCast3D (occlusion test). There's a fair amount of code, but once you understand the core pattern, adapting it to your project is straightforward.
The 3-Step Detection Pipeline
"The player is in front of the enemy, but there's a wall in between." "The player is right behind the guard, so they shouldn't be spotted." To handle situations like these correctly, the vision system processes detection through three efficient stages.
| Stage | Method | Purpose |
|---|---|---|
| 1. Range Check | Area3D (CollisionShape3D) | Is the target within detection range? |
| 2. Angle Check | Dot product | Is the target within the field of view? |
| 3. Occlusion Check | RayCast3D | Is the line of sight blocked by walls? |
Why separate stages?: By using Area3D to early-reject out-of-range targets, you reduce the number of expensive RayCast3D calls. For example, even with 100 NPCs in the scene, if only 3 are within the Area3D, you only need 3 raycast checks.
Understanding the Dot Product
If "dot product" sounds unfamiliar, don't worry. In this context, it's simply a number that tells you how closely two directions align.
- 1.0: Pointing the same direction (directly ahead, 0 degrees)
- 0.5: Slightly offset (60 degrees)
- 0.0: Perpendicular (90 degrees, to the side)
- -1.0: Opposite directions (directly behind, 180 degrees)
For FOV detection, we calculate the dot product between "the enemy's forward direction" and "the direction from the enemy to the target." For a 120-degree FOV, the half angle is 60 degrees and cos(60°) = 0.5. If the dot product is 0.5 or higher, the target is within the field of view; below 0.5 means outside.
Scene Tree Structure
Enemy (CharacterBody3D)
├── FOVDetector (Node3D)
│ ├── DetectionArea (Area3D)
│ │ └── CollisionShape3D (SphereShape3D, radius=15)
│ └── RayCast3D
├── MeshInstance3D
└── CollisionShape3D
Basic FOV Detection Implementation
With the 3-step structure in mind, let's look at the actual script. This is the core of the article. For targets inside the Area3D range, we check the angle using dot product, then verify line of sight with RayCast3D. This single script gives enemies a realistic "forward-only" field of view.
# fov_detector.gd
extends Node3D
@export var fov_angle: float = 120.0 # Field of view (degrees)
@export var detection_range: float = 15.0 # Detection distance
@onready var detection_area: Area3D = $DetectionArea
@onready var ray: RayCast3D = $RayCast3D
# Candidate targets within detection range
var targets_in_range: Array[Node3D] = []
signal target_detected(target: Node3D)
signal target_lost(target: Node3D)
func _ready() -> void:
detection_area.body_entered.connect(_on_body_entered)
detection_area.body_exited.connect(_on_body_exited)
ray.enabled = false # Used manually
# Track previously detected targets (emit signal only on state change)
var _detected_targets: Array[Node3D] = []
func _physics_process(_delta: float) -> void:
for target in targets_in_range:
var is_visible = _can_see_target(target)
var was_visible = target in _detected_targets
if is_visible and not was_visible:
_detected_targets.append(target)
target_detected.emit(target)
elif not is_visible and was_visible:
_detected_targets.erase(target)
target_lost.emit(target)
func _can_see_target(target: Node3D) -> bool:
var to_target = (target.global_position - global_position)
var distance = to_target.length()
# Distance check
if distance > detection_range:
return false
# Angle check (dot product)
var forward = -global_transform.basis.z.normalized()
var direction = to_target.normalized()
var dot = forward.dot(direction)
# Compare against the cosine of the half FOV angle
var half_angle_cos = cos(deg_to_rad(fov_angle / 2.0))
if dot < half_angle_cos:
return false # Outside the field of view
# Occlusion check (RayCast3D)
ray.target_position = to_target
ray.force_raycast_update()
if ray.is_colliding():
var collider = ray.get_collider()
return collider == target # Did the ray hit the target itself?
return true # Ray hit nothing = no occlusion (detection range already checked by distance)
func _on_body_entered(body: Node3D) -> void:
if body.is_in_group("player"):
targets_in_range.append(body)
func _on_body_exited(body: Node3D) -> void:
targets_in_range.erase(body)
if body in _detected_targets:
_detected_targets.erase(body)
target_lost.emit(body)
Understanding the Code Flow
Here's how the script works step by step.
_ready(): Connects the Area3D'sbody_entered/body_exitedsignals. When the player enters the range, they're added totargets_in_range; when they leave, they're removed_physics_process(): Every frame, calls_can_see_target()for all targets in range and emits signals only when the visibility state changes from the previous frame_can_see_target(): Runs the 3-step detection pipeline in order:- Distance check: Returns
falseimmediately if farther thandetection_range - Angle check: Uses the dot product to determine if the target is within the FOV
- Occlusion check: Casts a ray with RayCast3D to verify no walls block the view
- Distance check: Returns
The _detected_targets array tracking previous state is the key design detail. It ensures that signals fire only at the moment of detection or loss -- not every single frame -- preventing duplicate notifications.
tips: The
forwarddirection is-global_transform.basis.z. In Godot's 3D space, the negative Z axis points "forward."
tips: Set the
collision_maskof RayCast3D correctly. Include both wall/obstacle layers and the player layer, but exclude irrelevant objects like decorations.
Improving Accuracy with Multiple Raycasts
The basic FOV detection works well, but a single ray has its limits. For instance, if the player is crouching behind a low wall, a ray aimed at their feet hits the wall -- but their head is clearly visible. A single ray would miss this.
Casting rays to multiple points (head, chest, feet) solves this problem. If any one of the rays reaches the target, detection succeeds. Use this as a drop-in replacement for the basic _can_see_target.
# Multi-point line-of-sight check (replaces basic _can_see_target)
func _can_see_target_multi(target: Node3D) -> bool:
var to_target = target.global_position - global_position
# Distance check (same as basic version)
if to_target.length() > detection_range:
return false
# Angle check (same as above)
var forward = -global_transform.basis.z.normalized()
var dot = forward.dot(to_target.normalized())
if dot < cos(deg_to_rad(fov_angle / 2.0)):
return false
# Multiple check points (head, chest, feet)
var check_offsets = [
Vector3(0, 1.7, 0), # Head
Vector3(0, 1.0, 0), # Chest
Vector3(0, 0.1, 0), # Feet
]
for offset in check_offsets:
var check_pos = target.global_position + offset
var direction = check_pos - global_position
ray.target_position = direction
ray.force_raycast_update()
if ray.is_colliding():
if ray.get_collider() == target:
return true # Visible at one point = detected
else:
return true # No occlusion
return false # All points are occluded
tips: The Y values in
check_offsetsare relative to the CharacterBody3D's origin, which is typically at the feet. If your character model has its origin at the center, adjust the offset values accordingly.
The key difference from the basic _can_see_target is casting 3 rays instead of 1 and succeeding if any single ray reaches the target. This creates realistic detection where a player crouching behind cover can still be spotted if their head is exposed.
Graduated Alert Level Management
Now that vision detection works, the question is what to do with the result. In stealth games like Metal Gear Solid or Hitman, getting spotted doesn't mean instant game over -- a "?" mark appears and alert gradually builds. Let's implement this graduated alert system.
The AlertSystem manages three distinct states.
| State | Meaning | Gameplay |
|---|---|---|
| UNAWARE | Not aware of the player | Normal patrol behavior |
| SUSPICIOUS | Sensed something | Stops and looks around |
| ALERT | Confirmed sighting | Pursues and attacks the player |
The suspicion level (suspicion_level) increases while the target is visible and gradually decays when lost. When it reaches the threshold (alert_threshold), the enemy transitions to the ALERT state.
# alert_system.gd
extends Node
enum AlertState { UNAWARE, SUSPICIOUS, ALERT }
@export var suspicion_rate: float = 30.0 # Suspicion increase rate (%/sec)
@export var alert_threshold: float = 100.0 # Threshold to enter alert state
@export var suspicion_decay: float = 15.0 # Suspicion decrease rate (%/sec)
var current_state: AlertState = AlertState.UNAWARE
var suspicion_level: float = 0.0
signal state_changed(new_state: AlertState)
func update_detection(is_visible: bool, delta: float) -> void:
if is_visible:
suspicion_level += suspicion_rate * delta
else:
suspicion_level -= suspicion_decay * delta
suspicion_level = clampf(suspicion_level, 0.0, alert_threshold)
_evaluate_state()
func _evaluate_state() -> void:
var new_state: AlertState
if suspicion_level >= alert_threshold:
new_state = AlertState.ALERT
elif suspicion_level > 0.0:
new_state = AlertState.SUSPICIOUS
else:
new_state = AlertState.UNAWARE
if new_state != current_state:
current_state = new_state
state_changed.emit(current_state)
Each time update_detection() is called, suspicion_level increases or decreases. clampf() keeps it within the 0-to-threshold range, and _evaluate_state() determines the current state based on the suspicion level. The state_changed signal fires only on transitions, making it easy to trigger effects like "show a ? icon the moment the enemy becomes SUSPICIOUS."
Integrating with FOVDetector
The AlertSystem doesn't do much on its own. Let's connect it with the FOVDetector we built earlier so that suspicion only builds while the target is actually visible.
# enemy_ai.gd
extends CharacterBody3D
@onready var fov: Node3D = $FOVDetector
@onready var alert: Node = $AlertSystem
func _ready() -> void:
fov.target_detected.connect(_on_target_detected)
fov.target_lost.connect(_on_target_lost)
alert.state_changed.connect(_on_state_changed)
var _seeing_target: bool = false
func _on_target_detected(_target: Node3D) -> void:
_seeing_target = true
func _on_target_lost(_target: Node3D) -> void:
_seeing_target = false
func _physics_process(delta: float) -> void:
alert.update_detection(_seeing_target, delta)
func _on_state_changed(new_state) -> void:
match new_state:
AlertSystem.AlertState.UNAWARE:
print("Resuming normal patrol")
AlertSystem.AlertState.SUSPICIOUS:
print("Something's there... investigating")
AlertSystem.AlertState.ALERT:
print("Spotted! Initiating pursuit")
Best Practices
Here are some practical tips for integrating the vision system into a real project while balancing performance and gameplay quality.
| Topic | Recommendation | Reason |
|---|---|---|
| Check frequency | Use a Timer at 0.1-0.2s intervals instead of every frame | Better performance |
| Raycast count | 2-3 rays | Too many is expensive, too few is inaccurate |
| Collision layers | Assign a dedicated layer | Exclude unnecessary collision checks |
| Detection shape | SphereShape3D | Set it larger than the FOV for early rejection |
| Alert levels | 3 stages (UNAWARE/SUSPICIOUS/ALERT) | Give the player time to react |
| Debugging | Visualize the FOV cone with ImmediateMesh or @tool scripts | Makes tuning much easier |
Optimizing Check Frequency with Timer
Running visibility checks every frame in _physics_process can hurt performance when many enemies are active. Using a Timer at 0.1-0.2 second intervals dramatically reduces the load with barely any perceptible difference in detection responsiveness.
# Replace _physics_process with a Timer-based detection loop
func _ready() -> void:
detection_area.body_entered.connect(_on_body_entered)
detection_area.body_exited.connect(_on_body_exited)
ray.enabled = false
# Detection timer (0.15s interval)
var timer = Timer.new()
timer.wait_time = 0.15
timer.timeout.connect(_check_visibility)
add_child(timer)
timer.start()
func _check_visibility() -> void:
for target in targets_in_range:
var is_visible = _can_see_target(target)
var was_visible = target in _detected_targets
if is_visible and not was_visible:
_detected_targets.append(target)
target_detected.emit(target)
elif not is_visible and was_visible:
_detected_targets.erase(target)
target_lost.emit(target)
At 60 FPS, per-frame checking means 60 checks per second. With a 0.15s timer, that drops to about 7 checks per second. Even with 10 enemies, that's only 70 checks/second total, with virtually no perceptible difference in detection accuracy.
Debug Visualization of the FOV
Being able to see the actual FOV range in the editor makes tuning angles and distances much easier. Godot 4 does not have a built-in DebugDraw class, but you can use ImmediateMesh or @tool scripts to preview the vision cone in the editor.
# @tool makes the script run in the editor
@tool
extends Node3D
@export var fov_angle: float = 120.0
@export var detection_range: float = 15.0
@export var show_debug: bool = true
func _process(_delta: float) -> void:
if show_debug and Engine.is_editor_hint():
queue_redraw() # Trigger 3D redraw
tips: Keep
show_debugset totrueduring development for visual feedback, then set it tofalseor remove the@toolannotation for release builds. See the official documentation for details on drawing fan shapes withImmediateMesh.
Summary
- Use a 3-step pipeline of Area3D (range) + dot product (angle) + RayCast3D (occlusion) for efficient FOV detection
- Calculate
forward.dot(direction)and compare againstcos(half angle)to determine if a target is within the field of view - Cast multiple rays (head, chest, feet) to detect partially visible targets
- Track state with
_detected_targetsand emit signals only on changes to prevent duplicate notifications - Manage alert levels gradually -- build suspicion over time instead of instant detection for stealth tension
- Optimize performance with Timer-based check intervals and dedicated collision layers