Part 01 — What the enemy is allowed to see
You will build: A patrolling enemy with three separate perception gates — a range check, a vision arc, and a line-of-sight ray — that reports true only when all three agree, and drops back to false the instant any one of them stops being true. When you finish, you can walk behind a crate and watch the enemy keep patrolling as if you were never there.
The enemy that knows everything#
Start with the version everyone writes first. Make a CharacterBody2D called Enemy, give it a CollisionShape2D and a Sprite2D, and attach this script.
extends CharacterBody2D
@export var chase_range: float = 300.0
var player: Node2D = null
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
func _physics_process(_delta: float) -> void:
if player == null:
return
if global_position.distance_to(player.global_position) < chase_range:
chase()
else:
patrol()
You need chase() and patrol() to exist before this runs. Keep them crude on purpose — the point is to watch behaviour, not to build a movement system yet.
@export var speed: float = 90.0
@export var patrol_points: Array[Vector2] = []
var _patrol_index: int = 0
func chase() -> void:
velocity = global_position.direction_to(player.global_position) * speed
move_and_slide()
func patrol() -> void:
if patrol_points.is_empty():
velocity = Vector2.ZERO
move_and_slide()
return
var target: Vector2 = patrol_points[_patrol_index]
if global_position.distance_to(target) < 8.0:
_patrol_index = (_patrol_index + 1) % patrol_points.size()
target = patrol_points[_patrol_index]
velocity = global_position.direction_to(target) * speed
move_and_slide()
Your player scene needs to be in the player group for get_first_node_in_group to find it. Select the player root node, open the Node dock, switch to Groups, and add player there. Do it in the editor rather than calling add_to_group("player") from a script: add_to_group takes a second argument, persistent, and it defaults to false, so a group added in code exists at runtime only and is never written into the .tscn. That distinction matters the day you start saving and reloading scenes.
Fill patrol_points in the inspector with two positions a few hundred pixels apart, in global coordinates. Drop a StaticBody2D wall between them, thick and opaque. Run it.
Feel the failure before you fix it#
Walk toward the enemy. It chases. So far so good.
Now stand behind the wall, well inside 300 pixels. The enemy walks straight at the stone and pushes into it, facing you through solid rock. Back off past 300 and step sideways so a corner hides you completely, then walk back in — the enemy is already turning toward you before you have any line of sight on it.
Nothing crashes. There is no error to search for. The bug is that the enemy is omniscient, and the cost of omniscience is not a technical one.
Here is the precise cost. A player builds a mental model of what an enemy knows and plays against that model. Sneak behind the crate, the guard loses you, you get to move. That loop is the whole game. When the enemy's knowledge is distance_to, the player's model is always wrong, so there is no plan to make and no mistake to learn from. Getting spotted feels arbitrary. Escaping feels arbitrary. The reader can feel this on the first playthrough long before they can name it, and it will not be fixed by lowering chase_range — a smaller sphere of omniscience is still omniscience.
Keep the file. You are about to replace one line of it.
Three questions, three gates#
"Can I see you" is not one question. It is three, and each has its own failure mode:
- Am I close enough? Distance. You already have this.
- Are you in front of me? A vision arc around the direction the enemy faces.
- Is anything between us? A ray from the enemy's eyes to the player, stopped by walls.
Write them as three separate checks that each answer one question and only one. This is not decoration. Almost every see-through-walls bug is a missing gate rather than a broken one, and when the gates are tangled into a single boolean expression you cannot tell which one is missing. Separation also buys you something concrete in part 03: alert enemies get a wider arc, sleepy ones get a shorter range, and you tune each number without touching the others.
The shape of the finished function:
func can_see_player() -> bool:
if player == null:
return false
if not _in_range(player.global_position):
return false
if not _in_arc(player.global_position):
return false
if not _has_line_of_sight(player.global_position):
return false
return true
Early returns, cheapest test first. The range check is a subtraction and a square root; the ray is a physics query. Ordering them this way means a player standing across the level costs you almost nothing.
Range is the one you already have:
func _in_range(target: Vector2) -> bool:
return global_position.distance_to(target) <= chase_range
The arc, with a dot product#
The arc gate asks whether the target sits inside a cone centred on the enemy's facing direction. Two vectors give you the answer: where the enemy is looking, and where the player is relative to the enemy.
@export_range(0.0, 180.0, 1.0) var vision_angle_degrees: float = 70.0
var facing: Vector2 = Vector2.RIGHT
func _in_arc(target: Vector2) -> bool:
var to_target: Vector2 = global_position.direction_to(target)
var threshold: float = cos(deg_to_rad(vision_angle_degrees) * 0.5)
return facing.dot(to_target) >= threshold
vision_angle_degrees is the full width of the cone, so a 70-degree cone extends 35 degrees to each side. That is why the half-angle is what goes into cos().
Why a cosine threshold works: for two unit vectors, dot() returns the cosine of the angle between them. Cosine falls as the angle grows, so "the angle is smaller than the half-angle" and "the dot product is larger than the cosine of the half-angle" are the same statement. A dot product of 1.0 means the target is dead ahead, 0.0 means exactly 90 degrees off, negative means behind.
The condition that makes this true is that both vectors are normalized. The class reference states it as a condition, not as advice: the -1..1 range "when using unit (normalized) vectors" is the whole basis of the comparison. direction_to already normalizes the right-hand side — a.direction_to(b) is (b - a).normalized() — so the one that can bite you is facing.
Skip normalization on facing and the code still runs, still compiles, and produces a cone whose width changes with distance. The dot product scales with the length of the un-normalized vector, so a longer vector clears the threshold at wider angles. In play that reads as an enemy with a narrow slit of vision up close and near-total awareness far away, which is exactly backwards from how vision feels and impossible to tune out.
There is a readable alternative:
func _in_arc(target: Vector2) -> bool:
var to_target: Vector2 = global_position.direction_to(target)
return abs(facing.angle_to(to_target)) <= deg_to_rad(vision_angle_degrees) * 0.5
angle_to returns a signed angle, ranging from -PI to PI. Without abs(), every target on one side of the enemy produces a negative angle, which is always less than the half-angle, so the test passes unconditionally on that flank. The result is a half cone: the enemy detects everything to its left, including behind it, and behaves correctly on the right. That asymmetry is genuinely hard to spot from play because half your tests look right.
Use whichever form you will still understand in a month. dot() is faster and it is the version you will find in other people's code; angle_to is the version you can read aloud. Neither is wrong.
The facing vector that quietly becomes zero#
facing has to come from somewhere. The tempting source is velocity:
facing = velocity.normalized()
This is a landmine, and it never raises an error. normalized() returns (0, 0) when the vector's length is zero. So the moment the enemy stops moving — at a patrol waypoint, against a wall, at the top of a jump, while playing an idle animation — facing becomes the zero vector, every dot product against it is exactly 0.0, and 0.0 fails a threshold like cos(35°) ≈ 0.819. The enemy detects nobody. Silently. Permanently, if it never moves again.
Cache the last non-zero direction and keep using it while stopped:
const FACING_EPSILON: float = 0.01
func _update_facing() -> void:
if velocity.length_squared() > FACING_EPSILON:
facing = velocity.normalized()
Call it in _physics_process after you set velocity. Now a standing enemy keeps looking wherever it was last walking, which is also the behaviour a player expects.
length_squared() rather than length() because you are only comparing against a constant and the square root buys nothing. That is a small thing, but this runs every frame on every enemy.
The occlusion gate#
Add a RayCast2D as a child of the enemy. Name it SightRay. Position it where the eyes should be — a small vertical offset from the body centre usually reads better than the feet.
In the inspector, uncheck Enabled. That looks wrong and is not. A RayCast2D with enabled on recomputes itself every physics frame, aimed wherever it happens to point; you do not want that, because you are going to aim it at a specific target and fire it on demand. force_raycast_update() works even when enabled is false, and that combination is the standard pattern for an on-demand line-of-sight probe.
@onready var sight_ray: RayCast2D = $SightRay
func _has_line_of_sight(target: Vector2) -> bool:
sight_ray.target_position = sight_ray.to_local(target)
sight_ray.force_raycast_update()
if not sight_ray.is_colliding():
return true
var hit: Object = sight_ray.get_collider()
return hit is Node and (hit as Node).is_in_group("player")
Three things in that function are each worth more than the line they occupy.
target_position is local, get_collision_point is global#
target_position is documented as "the ray's destination point, relative to this raycast's Node2D.position." It is a local offset, not a place in the world. So aiming at the player is:
sight_ray.target_position = sight_ray.to_local(target)
and never sight_ray.target_position = target. The raw-global version is a particularly cruel bug because it appears to work. If your test scene has the enemy sitting near the world origin, local and global coordinates are nearly the same, the ray lands roughly where you meant, and detection behaves. Move the enemy to (2400, 1600) for the second room and the ray shoots off to a point 2400 pixels past the player. Nothing errors. Detection becomes nonsense in exactly one part of the level.
The mirror of this catches people on the way back out. get_collision_point() returns the hit "in the global coordinate system" — the opposite convention from the property that aimed the ray. Feed that global point into a calculation that expects local space and you have written the same bug reflected. When you start drawing debug lines in part 03, this is the one that will get you.
The ray answers with last frame's data#
The class reference is explicit: RayCast2D "calculates intersection every physics frame, and it holds the result until the next physics frame." Setting target_position does not recompute anything. Read is_colliding() right after retargeting and you get the answer to the previous frame's question.
The symptom does not look like a raycast problem. It looks like an enemy that notices you one frame late — barely visible standing still, obvious the moment either of you moves fast, and downright broken if the enemy shoots, because the shot goes to where you were rather than where you are. People spend hours in the movement code chasing this.
force_raycast_update() "updates the collision information for the ray immediately, without waiting for the next _physics_process call." Set the target, force the update, then read. In that order, every time.
Reading the hit needs a guard#
get_collider() returns Object, not Node2D. Writing var body: Node2D = sight_ray.get_collider() is a runtime error waiting for the first collider that is not a Node2D, and there is no compile-time complaint. Test the type before you commit to it, which is what hit is Node does above.
Then identity. is_in_group("player") is the cheap check: it costs a set lookup, it does not require the enemy script to know that a Player class exists, and it keeps the enemy usable in a scene where the "player" is a possessed NPC or a training dummy. Type-checking against a class_name works too and gives you autocomplete on the result — the deciding question is whether the enemy ever needs to call anything on the target. If all it needs is "yes, that is a player," the group test wins.
Filtering, where silent non-detection lives#
Your ray will not work yet. It will report is_colliding() == false forever, or true forever, and neither produces a warning.
Layers and masks. In Project Settings → General → Layer Names → 2D Physics, name your layers. Something like: 1 world, 2 player, 3 enemy. Then set the ray's mask from code in _ready:
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
sight_ray.set_collision_mask_value(1, true)
sight_ray.set_collision_mask_value(2, true)
The ray must hit walls (layer 1) and the player (layer 2). It needs walls because a wall hit is what proves occlusion, and it needs the player because a player hit is what proves the opposite. A mask with only the player in it makes the ray pass through every wall in the game and report sight through stone — the exact bug you set out to fix, now hidden one level deeper.
Detection here is directional and there is no warning when you get the direction wrong. The ray's mask must contain the player's layer. Setting the player's mask to include the enemy's layer does nothing for vision. Setting both objects to the same layer and expecting them to see each other does nothing either. Layer is "what I am"; mask is "what I scan for". The pair only works one way at a time.
Use set_collision_mask_value(2, true) rather than bit math, and here is the off-by-one it saves you from. Inspector layer N is bit N-1. So collision_mask = 1 << 3 does not enable layer 3 — it enables layer 4. The engine's own documentation writes layers 1, 3 and 4 as (1 << 1 - 1) | (1 << 3 - 1) | (1 << 4 - 1), which is where the shift-by-one-less comes from. Every layer in hand-written bit math is off by one unless you remember, and you will not remember at 1am. set_collision_mask_value takes the 1-based number printed next to the checkbox in the inspector.
Areas. collide_with_areas defaults to false on RayCast2D. If your player's hittable surface is an Area2D hurtbox rather than the CharacterBody2D itself, the ray passes straight through it and sees the wall behind. There is no error, no warning, no log line — the enemy is blind to the player specifically, while correctly detecting every wall you throw at it, which reads as a wildly confusing bug.
sight_ray.collide_with_areas = true
Turn it on only if you actually need it. A ray that hits areas also hits trigger volumes, water zones, and camera-region markers, any of which will read as an opaque wall.
The enemy's own body. exclude_parent defaults to true and excludes exactly one node: the ray's direct parent. If SightRay is a child of the enemy root, you are covered. If you tidied the scene and put the ray under a Marker2D called Eyes, or under the sprite, then exclude_parent now skips the Marker2D — which has no collision shape and was never going to be hit — and the enemy's own CollisionShape2D is the first thing the ray strikes. Line of sight is permanently blocked, the enemy never sees anything, and the scene tree looks perfectly reasonable.
Two fixes. Keep the ray as a direct child of the body, or add an explicit exception:
sight_ray.add_exception(self)
The explicit version survives someone reorganising the scene later, which is an argument for using it even when the hierarchy is currently fine.
Wiring the gates in#
Replace the distance test in _physics_process with the composed check.
func _physics_process(_delta: float) -> void:
if can_see_player():
chase()
else:
patrol()
_update_facing()
_update_facing() goes last because chase() and patrol() are what set velocity. Call it first and you are facing according to last frame's movement, which is a subtler version of the same staleness problem the raycast had.
Here is the whole script, so you can check yours against it.
extends CharacterBody2D
const FACING_EPSILON: float = 0.01
@export var speed: float = 90.0
@export var chase_range: float = 300.0
@export_range(0.0, 180.0, 1.0) var vision_angle_degrees: float = 70.0
@export var patrol_points: Array[Vector2] = []
var player: Node2D = null
var facing: Vector2 = Vector2.RIGHT
var _patrol_index: int = 0
@onready var sight_ray: RayCast2D = $SightRay
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
sight_ray.set_collision_mask_value(1, true)
sight_ray.set_collision_mask_value(2, true)
sight_ray.add_exception(self)
func _physics_process(_delta: float) -> void:
if can_see_player():
chase()
else:
patrol()
_update_facing()
func can_see_player() -> bool:
if player == null:
return false
var target: Vector2 = player.global_position
if not _in_range(target):
return false
if not _in_arc(target):
return false
if not _has_line_of_sight(target):
return false
return true
func _in_range(target: Vector2) -> bool:
return global_position.distance_to(target) <= chase_range
func _in_arc(target: Vector2) -> bool:
var to_target: Vector2 = global_position.direction_to(target)
var threshold: float = cos(deg_to_rad(vision_angle_degrees) * 0.5)
return facing.dot(to_target) >= threshold
func _has_line_of_sight(target: Vector2) -> bool:
sight_ray.target_position = sight_ray.to_local(target)
sight_ray.force_raycast_update()
if not sight_ray.is_colliding():
return true
var hit: Object = sight_ray.get_collider()
return hit is Node and (hit as Node).is_in_group("player")
func _update_facing() -> void:
if velocity.length_squared() > FACING_EPSILON:
facing = velocity.normalized()
func chase() -> void:
velocity = global_position.direction_to(player.global_position) * speed
move_and_slide()
func patrol() -> void:
if patrol_points.is_empty():
velocity = Vector2.ZERO
move_and_slide()
return
var target: Vector2 = patrol_points[_patrol_index]
if global_position.distance_to(target) < 8.0:
_patrol_index = (_patrol_index + 1) % patrol_points.size()
target = patrol_points[_patrol_index]
velocity = global_position.direction_to(target) * speed
move_and_slide()
Note the empty-ray case. if not sight_ray.is_colliding(): return true says "nothing between us, so sight is clear." That is correct here because the range gate already ran — the ray only fires at targets you know are within chase_range, so "hit nothing" cannot mean "the player is beyond the ray's reach." Reorder the gates and that assumption breaks quietly. This is the kind of coupling worth a comment in your own copy.
If your enemy already has a state machine, feed can_see_player() into whatever decides between chasing and patrolling rather than branching in _physics_process. If it does not have one yet, leave it exactly as written — one function returning bool — and build the machine when the branching gets uncomfortable. The enum machine is the version that fits an enemy with four or five behaviours.
The fork: node or query#
There is a second way to cast a ray and it is not worse. PhysicsDirectSpaceState2D.intersect_ray runs a ray with no node involved:
func _has_line_of_sight_query(target: Vector2) -> bool:
var space: PhysicsDirectSpaceState2D = get_world_2d().direct_space_state
var query: PhysicsRayQueryParameters2D = PhysicsRayQueryParameters2D.create(
global_position, target, 0b11, [self.get_rid()]
)
var result: Dictionary = space.intersect_ray(query)
if result.is_empty():
return true
var hit: Object = result.collider
return hit is Node and (hit as Node).is_in_group("player")
Four differences that will trip you if you switch between the two APIs:
from and to are global. The exact opposite of target_position. This is the single most confusing thing about Godot having two raycast APIs, and switching a function from one to the other without changing the coordinate space produces a ray that points somewhere plausible-but-wrong.
A miss returns an empty Dictionary. Not null, not a dictionary of nulls. So result.collider throws an invalid-index error the first time the enemy looks at open sky — which, in a top-down game, is frame one. Branch on result.is_empty() (or truthiness: an empty Dictionary is falsy) before touching any key.
The hit location key is position, not point. The full key set on a hit is collider, collider_id, normal, position, rid, shape. Reaching for result.point out of muscle memory from the node API is an invalid-index error too.
exclude is typed Array[RID]. Writing [self.get_rid()] makes the type obvious and survives the day you move this into a helper function. Without an exclusion the first thing the ray hits is the enemy's own collision shape, and line of sight is blocked forever.
And one hard constraint that is not a style preference. Space is only safe to touch inside _physics_process. The documentation is blunt about why: physics "runs by default in the same thread as game logic, but may be set to run on a separate thread," so "the only time accessing space is safe is during the Node._physics_process() callback." Anywhere else and you may get a locked-space error.
That rules out more designs than it sounds like. It rules out checking sight from _process. It rules out checking from _ready. And it rules out the most natural optimisation a reader reaches for next: a Timer that re-checks vision every 0.2 seconds instead of every frame. Timer.process_callback defaults to TIMER_PROCESS_IDLE, so a timeout handler fires outside the physics callback and the query inside it errors. Set process_callback = Timer.TIMER_PROCESS_PHYSICS, or have the timeout set a flag that _physics_process reads. Part 03 uses the second approach.
The deciding question: is this one ray on a fixed offset from the enemy, or a variable number of rays per frame? A RayCast2D node is visible in the editor, gizmo-drawn while you position it, and free to debug. That is worth a lot while you are still learning what your enemy's sight should feel like. The query version needs no node, so it scales to "sample five points across the player's silhouette" or "check three cover positions" without five nodes in the scene tree. One fixed probe: use the node. A varying number: use the query.
Checkpoint — definition of done#
- Standing directly in front of the enemy inside 300 pixels, it chases you.
- Standing behind a
StaticBody2Dwall inside 300 pixels, it patrols as if you are not there. - Standing behind the enemy inside 300 pixels with clear line of sight, it patrols — the arc gate rejects you.
- Stepping out from behind cover makes it chase within one frame, with no visible lag.
- Breaking line of sight makes it stop chasing immediately (it will do something dumb after that — that is part 02's problem, not a bug here).
- The enemy standing still at a patrol waypoint still detects a player who walks into its cone. If it goes blind while stopped, your facing vector is going to zero.
- Moving the whole enemy to a position far from the world origin does not change any of the above.
- Setting
vision_angle_degreesto 20 produces a visibly narrow cone, and 160 produces a nearly all-round one, at every distance. A cone that widens with distance means an un-normalizedfacing. - You can explain: why
force_raycast_update()is required after settingtarget_position, given that the ray would have updated itself on the next physics frame anyway.
Stretch (no instructions)#
- Give the enemy two ranges: a wide close-in "notice" radius that ignores the arc entirely, and the narrow long cone. Model peripheral vision.
- Cast three rays instead of one — head, centre, feet — and require two hits before calling it sight. Watch how differently partial cover reads.
- Make
chase_rangea property of an exported resource so a "guard" and a "hound" share one script.
If you get stuck#
- The enemy never chases, anywhere, with no errors. Print
sight_ray.is_colliding()andsight_ray.get_collider()every frame. If the collider is the enemy itself, the ray is not a direct child of the body andexclude_parentis not covering it — addsight_ray.add_exception(self). - The enemy never chases, and the ray hits nothing at all. The mask does not include the player's layer, or the player's collidable part is an
Area2Dandcollide_with_areasis stillfalse. - The enemy sees through walls again. The mask includes the player's layer but not the world layer, so the ray passes through geometry and reports a clear path.
- Detection works near the level start and fails elsewhere.
target_positionis being set to a global position. Wrap it insight_ray.to_local(). - The enemy notices you exactly one frame late, or fires where you were.
force_raycast_update()is missing, or it is being called beforetarget_positionis set rather than after. - The enemy goes blind whenever it stops moving.
facingisvelocity.normalized()with no zero-length guard, so it became(0, 0)and every dot product is 0.0. - The enemy detects you on one side only, including behind it. The
angle_toversion withoutabs(). The signed result makes every negative angle pass. - The cone is narrow up close and enormous far away.
facingis not normalized, so the dot product scales with its length. Invalid access to property or key 'collider'. Theintersect_rayversion with no empty-Dictionary guard. Checkresult.is_empty()first.Invalid access to property or key 'point'. The hit position key isposition.- An error about space being locked. A space-state query is running outside
_physics_process— most often from aTimerstill on the defaultTIMER_PROCESS_IDLEcallback. get_first_node_in_group("player")returns null. The group was added from code (runtime-only,persistentisfalseby default) rather than in the Node dock, or the player node enters the tree after the enemy's_readyruns.
Next: Part 02 — What it remembers after you hide. Read it once your enemy loses you correctly but stupidly — the instant snap back to patrol is what memory fixes.