【Godot】Stealth Game FOV System: Area3D + RayCast3D Implementation

Created: 2026-02-08

Build a stealth-game FOV (field of view) system using Area3D for detection range, dot product for angle checks, and RayCast3D for line-of-sight verification. Covers multi-raycast precision and graduated alert levels.

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.

StageMethodPurpose
1. Range CheckArea3D (CollisionShape3D)Is the target within detection range?
2. Angle CheckDot productIs the target within the field of view?
3. Occlusion CheckRayCast3DIs 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.

  1. _ready(): Connects the Area3D's body_entered / body_exited signals. When the player enters the range, they're added to targets_in_range; when they leave, they're removed
  2. _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
  3. _can_see_target(): Runs the 3-step detection pipeline in order:
    • Distance check: Returns false immediately if farther than detection_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

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 forward direction is -global_transform.basis.z. In Godot's 3D space, the negative Z axis points "forward."

tips: Set the collision_mask of 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_offsets are 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.

StateMeaningGameplay
UNAWARENot aware of the playerNormal patrol behavior
SUSPICIOUSSensed somethingStops and looks around
ALERTConfirmed sightingPursues 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.

TopicRecommendationReason
Check frequencyUse a Timer at 0.1-0.2s intervals instead of every frameBetter performance
Raycast count2-3 raysToo many is expensive, too few is inaccurate
Collision layersAssign a dedicated layerExclude unnecessary collision checks
Detection shapeSphereShape3DSet it larger than the FOV for early rejection
Alert levels3 stages (UNAWARE/SUSPICIOUS/ALERT)Give the player time to react
DebuggingVisualize the FOV cone with ImmediateMesh or @tool scriptsMakes 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_debug set to true during development for visual feedback, then set it to false or remove the @tool annotation for release builds. See the official documentation for details on drawing fan shapes with ImmediateMesh.

Summary

  • Use a 3-step pipeline of Area3D (range) + dot product (angle) + RayCast3D (occlusion) for efficient FOV detection
  • Calculate forward.dot(direction) and compare against cos(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_targets and 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

Further Reading