Part 01 — the chase that does not need a path
You will build: A CharacterBody2D enemy that hunts the player with two lines of code, then the same enemy with two RayCast2D whiskers that steer it around a pillar. You finish by running a four-question test against your own level that tells you whether to keep reading this shelf or close it.
Pathfinding is the most over-adopted system in hobby game development. It gets installed before anyone checks whether the level needs it, and then it sits in the project demanding maintenance forever. This part exists so you never install it blind. You are going to build the version that has no path in it at all, watch it succeed, watch it fail, patch the failure that can be patched, and then look hard at the failure that cannot.
By the end you will have a specific reason to continue or a specific reason to stop. Both are wins.
The scene you need#
Make a new scene. Nothing here is precious — this is a lab, not a game.
Node2DnamedChaseLabas the root.- A
CharacterBody2DnamedPlayerwith aCollisionShape2D(aCircleShape2D, radius 8) and aSprite2Dor aColorRectso you can see it. Give it a script that moves on WASD or arrow keys. - A
CharacterBody2DnamedHunterwith the same shape setup, a different colour, positioned across the room from the player. - A few
StaticBody2Dwalls. Start with none. You will add them one at a time, in a specific order, and the order is the whole lesson.
The player script can be anything that moves. Here is a minimal one so the lab runs today:
class_name Player
extends CharacterBody2D
@export var speed: float = 220.0
func _physics_process(_delta: float) -> void:
var input_direction: Vector2 = Input.get_vector(
"ui_left", "ui_right", "ui_up", "ui_down"
)
velocity = input_direction * speed
move_and_slide()
Input.get_vector() returns a normalized Vector2 built from four action names, so diagonal movement is not faster than straight movement. The four ui_* actions ship with every Godot project, which is why this runs with no input map setup.
Put Player in a group so the hunter can find it without a hard-coded path. Select the node, open the Node dock, Groups tab, add player.
Direct pursuit: the version with no path in it#
Attach this to Hunter.
class_name Hunter
extends CharacterBody2D
@export var speed: float = 120.0
var _target: Node2D = null
func _ready() -> void:
_target = get_tree().get_first_node_in_group("player")
func _physics_process(_delta: float) -> void:
if _target == null:
return
var direction: Vector2 = global_position.direction_to(_target.global_position)
velocity = direction * speed
move_and_slide()
That is the entire pursuit system. Three lines of logic.
direction_to() is a Vector2 method that returns a normalized vector pointing from one position to another. It is doing the same work as (target - self).normalized() but it reads as a sentence and it will not surprise you with a zero-length division at close range — Godot returns a zero vector there rather than NaN.
_target is looked up once in _ready() rather than every frame, because a group scan walks the scene tree and there is no reason to pay for it sixty times a second when the answer does not change.
Run it in an empty room. Move around. Watch what happens.
The hunter turns to face you the instant you change direction. It cuts corners around your movement. It closes distance when you stop. It does not overshoot. If you did not know how it was implemented, you would not guess it was three lines.
State this plainly to yourself, because it is true and the rest of this shelf will try to make you forget it: this is the correct, finished, shipping-quality answer for a large number of games. It costs one vector subtraction and one normalize per agent per frame. It needs no level data, no baking step, no synchronization, and no update when you move a wall in the editor. Twin-stick shooters, bullet hell, most arena brawlers, swarm enemies, homing projectiles, and pets that follow the player all ship with exactly this.
Do not let the length of this shelf convince you that the short answer is the wrong answer.
The first wall: one pillar#
Drop a single square StaticBody2D between the hunter and the player. Give it a CollisionShape2D with a RectangleShape2D, something like 64 by 64. Stand directly on the far side of it.
Run it.
The hunter drives into the flat face of the pillar and stops making progress. But it does not stand still — move_and_slide() takes the part of the velocity that is blocked and projects the rest along the wall surface, so the hunter grinds sideways along the face. Depending on exactly where it hit, it either slides to a corner and gets around, or slides to the wrong corner and comes back.
Watch it for a while. Move a little to the left and right.
Here is the uncomfortable part. This does not look broken. It looks like an enemy that is trying. Slide-along-the-wall reads to a player as pathfinding that is not very good, not as an absence of pathfinding. That misreading is exactly why the naive version survives in shipped games far longer than tutorials admit. A dumb enemy that grinds around a pillar and eventually gets there is, for a lot of games, indistinguishable from a smart one.
So the pillar is not yet a reason to build anything. Keep it, and add the thing that is.
The second wall: the U#
Build a U-shaped wall out of three StaticBody2D segments — or one static body with three rectangle shapes, which is fewer nodes. Make it big enough to stand inside comfortably, maybe 250 pixels across. Point the opening away from where the hunter spawns.
Now stand inside the U.
Run it.
The hunter drives to the outside of the closed end, presses its nose against the bricks, and stays there. Forever. Not for a moment — forever. Leave the game running and go make a coffee. It will still be there.
This is the failure that no amount of tuning fixes, and it is worth naming precisely rather than vaguely.
Direct steering answers exactly one question every frame: which way is the target from where I am standing right now? That question has no memory of where the agent has already been and no lookahead into where a direction leads. It produces a vector that always points at the goal.
To get out of the U, the hunter must first move away from the player. It must accept a temporarily worse position — increased distance — in exchange for a route that eventually works. There is no value of speed, no smoothing, no acceleration curve, no amount of extra if statements around direction_to() that will ever produce a vector pointing away from the target, because the vector is the direction to the target. The information required to make that choice does not exist anywhere in the code.
That is the whole argument for pathfinding, and you have watched it happen instead of being told about it.
Before search: whiskers#
Do not jump to A* yet. There is a cheaper rung, and it is the right answer often enough that skipping past it is a mistake.
The idea: cast two short rays diagonally ahead of the hunter, and when one of them hits something, add a sideways push away from the hit. The hunter still steers at the target; it gets nudged off obstacles it is about to run into.
Add two RayCast2D children to Hunter. Name them WhiskerLeft and WhiskerRight. In the Inspector, leave enabled on (it is on by default) and leave target_position at whatever — the script sets it every frame, because the whiskers must rotate to follow the direction of travel.
class_name Hunter
extends CharacterBody2D
@export var speed: float = 120.0
@export var whisker_length: float = 48.0
@export var whisker_angle_degrees: float = 35.0
@export var avoid_strength: float = 1.6
@onready var _whisker_left: RayCast2D = $WhiskerLeft
@onready var _whisker_right: RayCast2D = $WhiskerRight
var _target: Node2D = null
func _ready() -> void:
_target = get_tree().get_first_node_in_group("player")
func _physics_process(_delta: float) -> void:
if _target == null:
return
var to_target: Vector2 = global_position.direction_to(_target.global_position)
_aim_whiskers(to_target)
var steering: Vector2 = to_target + _avoidance(to_target) * avoid_strength
velocity = steering.normalized() * speed
move_and_slide()
func _aim_whiskers(facing: Vector2) -> void:
var angle: float = deg_to_rad(whisker_angle_degrees)
_whisker_left.target_position = facing.rotated(-angle) * whisker_length
_whisker_right.target_position = facing.rotated(angle) * whisker_length
_whisker_left.force_raycast_update()
_whisker_right.force_raycast_update()
func _avoidance(facing: Vector2) -> Vector2:
var push: Vector2 = Vector2.ZERO
if _whisker_left.is_colliding():
push += facing.rotated(PI * 0.5)
if _whisker_right.is_colliding():
push += facing.rotated(-PI * 0.5)
return push
Three things in there deserve an explanation, because they are the parts that break when people write this from memory.
target_position on a RayCast2D is in local space, relative to the node. That is why the script assigns facing.rotated(...) * whisker_length directly instead of adding global_position to anything. If you ever see a raycast that behaves correctly only when the parent sits at the origin, this is the reason.
force_raycast_update() exists because raycasts normally refresh once per physics frame, at a point in the frame that may be before your script changed target_position. Without the forced update you are reading last frame's answer about this frame's aim, which produces avoidance that lags and jitters in a way that is genuinely hard to diagnose. Set the aim, force the update, then read is_colliding().
The rotated(PI * 0.5) terms are perpendicular to the direction of travel — one to each side. A left-whisker hit pushes right; a right-whisker hit pushes left. When both hit, the two pushes cancel and the hunter drives straight into the wall, which is a real limitation and not a bug in your typing. Head-on collisions are the case whiskers do not cover.
Run it against the pillar.
The hunter now curves around the pillar before touching it. No grinding, no stall at the flat face. It looks deliberate. This is a genuine, visible improvement for the cost of two nodes and about fifteen lines.
Now run it against the U.
It parks against the outside of the closed end exactly as before. The whiskers fire, both of them, the pushes cancel, and the hunter sits there.
Price whiskers honestly, because they earn their place:
- They cost two short raycasts per agent per frame. That is close to free at any sane agent count.
- They need no map data at all. No grid, no navigation mesh, no bake step, nothing to keep in sync when you edit the level. Move a wall in the editor and the whiskers handle it with zero extra work.
- They cover convex obstacles — pillars, crates, rocks, other agents if you put those on the ray's collision mask — which is most of what an arena or a bullet-hell room contains.
- They cannot solve concave geometry. Ever. Not with more whiskers, not with longer whiskers. The missing ingredient is a route, and a whisker is a nudge.
That last line is the sentence this whole part was built to earn. A nudge cannot substitute for a route, and now you have seen why rather than been told.
The four-question test#
Take the failures you produced and turn them into questions about your actual level. Not a hypothetical level — the one in your project, or the one in your design document.
1. Does concave geometry ever sit between an agent and its target? A U-shaped wall, an L-corridor, a room with one door, a building with an interior, a fence with a single gate. If the shortest straight line ever passes through a shape an agent cannot slide around, direct steering will stall against it.
2. Does an agent ever have to commit to a route that leads away from the target? Stairs down before stairs up. A corridor that loops. Two floors joined at one end. Any layout where "correct" and "toward" diverge for more than a moment.
3. Can the target hide behind cover? This is question one with intent behind it. If the player will deliberately put geometry between themselves and the enemy, and you want the enemy to answer that, you need a route. If hiding is not part of your game, this is a no.
4. Is the level large enough that a wrong guess wastes seconds? In a 600-pixel arena, an enemy that picks the wrong side of an obstacle loses half a second and nobody notices. In a 40-screen map, it loses ten seconds and reads as broken. Scale converts a small inaccuracy into a visible failure.
One yes means you need a search. Zero yeses means you are finished, and the right move is to close this shelf and go build the rest of your game.
Answer the four out loud, about your own level. Write the answers down somewhere. If they are all no, this shelf has done its job by saving you a system.
What continuing actually costs#
If you did get a yes, walk in with clear eyes about the bill, because the algorithm is the cheap part and almost every tutorial gets the emphasis backwards.
The search itself is genuinely inexpensive. A grid A* across a mid-size level runs in well under a millisecond, and — this is the part that surprises people — you do not run it every frame. You run it when something changes: when the target moves far enough to matter, when a door opens, on a timer of a few hundred milliseconds. Between searches the agent is walking a list of points, which is cheaper than the whisker version you already wrote.
The expense is everything wrapped around it:
- A representation of the level. A grid of cells, or a baked navigation mesh. Either way, something now describes your level a second time, in a format the pathfinder understands.
- Keeping that representation true. Every wall you add in the editor must reach the grid or the mesh. Every destructible crate, every opened door, every dynamically spawned obstacle must update it at runtime. A stale representation is worse than none — the agent walks confidently into a wall that the map says is not there.
- A repath policy. How often do you search again? Too often and you burn CPU and the path flickers. Too rarely and the agent walks to where the player used to be.
- Smoothing. A raw grid path is a staircase. Following it literally looks like an agent obeying graph paper. Making it look natural is its own piece of work.
- A plan for no path. Sometimes the destination is unreachable — inside a sealed room, off the mesh, on the other side of a closed door. The search returns nothing. If your movement code assumes there is always a first waypoint, that is a crash, and it will find you in production rather than in testing.
That maintenance burden, not the algorithm, is what makes pathfinding a decision rather than a default. The next four parts of this shelf are mostly about that list, not about A*.
Checkpoint — definition of done#
- The hunter closes on the player in an empty room and turns to follow when you change direction.
- With one square pillar between you, the hunter (before whiskers) visibly grinds along the flat face instead of stopping dead.
- Standing inside a U-shaped wall with the opening facing away, the hunter parks outside and stays there for at least thirty seconds without freeing itself.
- After adding both whiskers, the hunter curves around the pillar without touching it.
- After adding both whiskers, the hunter still parks outside the U — confirming the whiskers changed nothing about that case.
- You have written down your four-question answers about your own level, and you know whether you are continuing.
- You can explain: why no amount of tuning the steering vector will ever get the hunter out of the U, when tuning it did fix the pillar.
Stretch (no instructions)#
- Give the whiskers a collision mask that excludes the player, so the hunter does not try to avoid the thing it is chasing.
- Add a third, forward-facing whisker and decide what a head-on hit should do. There is a real design choice here and no obviously right answer.
- Make the hunter remember the last position where it could see the player, and steer to that instead of the live position. Notice how much smarter it looks in the pillar case, and that it still cannot solve the U.
- Spawn twenty hunters. Watch them stack into a single point on top of the player. Note what problem that is — it is not a pathfinding problem, and Part 05 names it.
If you get stuck#
- Hunter does not move at all →
_targetis null.get_first_node_in_group("player")returns null unless the player node is actually in a group named exactlyplayer, lowercase, added in the Node dock's Groups tab rather than typed into a script comment. - Hunter moves but passes through walls → your walls are
StaticBody2Dwith aCollisionShape2Dthat has a shape resource assigned. An emptyCollisionShape2Dshows a warning triangle in the scene tree and collides with nothing. - Hunter barely moves — a couple of pixels a second → you multiplied by
deltaas well as callingmove_and_slide().move_and_slide()applies delta internally, so your speed gets divided by the frame rate one time too many. - Hunter moves absurdly fast →
speedwas picked as a per-frame number.velocityis in pixels per second, somove_and_slide()multiplies whatever you chose by sixty. - Whiskers never report a collision → three usual causes: the ray's
collision_maskdoes not include the wall'scollision_layer,whisker_lengthis shorter than the distance the hunter covers before contact, or you readis_colliding()without callingforce_raycast_update()after changingtarget_position. - Hunter jitters violently near walls →
avoid_strengthis high enough that the avoidance term overwhelms the pursuit term, so it flips direction, loses the ray hit, flips back. Drop it toward 1.0 and lengthen the whiskers instead — an earlier, gentler push beats a late, hard one. - Whiskers point the wrong way, or only work when the hunter is near the origin →
RayCast2D.target_positionis local space. Addingglobal_positionto it is the usual mistake. - Hunter drives straight into a corner and stops even with whiskers on → both whiskers are colliding, so the two perpendicular pushes cancel exactly. This is the documented limit of the technique, not a typo in your code.
- Everything works but the hunter escapes the U eventually → your U is not actually concave from the hunter's spawn point, or a gap opened between wall segments. Check the segments overlap at the corners.
Next: Part 02 — a grid that knows where the walls are. Read it once you have a yes on the four-question test — it builds the level representation everything after it depends on.