Godot _draw Basics: Visualize Enemy AI Range and Targets While the Game Runs

Created: 2026-09-23Last updated: 2026-10-10

Draw an enemy AI's detection range and target with _draw() while the game runs, and compare it with the collision shapes to find bugs.

When you want to check how far an enemy AI notices you, or where it's heading right now, drawing circles and lines on the running screen makes it obvious at a glance. In Godot, a few lines in a node's _draw() let you draw circles, lines, and text on screen.

Print the distance with print and numbers like 180 and 150 just scroll by, without showing how far the detection range actually reaches. Draw it, and you spot mismatches in range or direction on the spot.

This article covers how to use _draw() to draw an enemy AI's detection distance, destination, and state on screen, and overlay them with the collision display to find mismatches.

A large outlined circle and a small translucent circle around an enemy, with a label above its head. The player stands at the edge of the large circle, and a line runs from the enemy to a flag

What You'll Learn

  • When _draw() gets called, and how to use queue_redraw()
  • The coordinates you draw in, and how to pass other nodes' positions with to_local()
  • When to use the Debug menu's displays and when to draw your own
  • Why you put the drawing in its own child node
  • Hands-on: draw circles, lines, and a state name on a guard enemy, find why it doesn't notice you, and fix it

Tested with: Godot 4.7

Sponsored

_draw() and queue_redraw()

_draw() isn't a function called every frame like _process(). It's called when the node first appears on screen, when it goes from hidden back to visible, when you ask for a redraw with queue_redraw(), and so on. What it drew is reused as-is until the next redraw.

The drawn circle, line, and label move along with the node, but the line's direction stays as it was when drawn and misses the target. Redrawing with queue_redraw makes it reach the target

The circles, lines, and text you draw stay attached to the node and move with it. What doesn't change is anything decided by values at the time of drawing: the direction and length of a line pointing at the player, and the contents of state text. If those values change, call queue_redraw() every frame in _process(). Calling it several times in the same frame on the same node still results in a single redraw.

func _process(_delta: float) -> void:
	queue_redraw()   # the values change, so ask for a redraw every frame

func _draw() -> void:
	draw_circle(Vector2.ZERO, 200.0, Color.SKY_BLUE, false, 2.0)   # an outline-only circle

Drawing functions are called inside _draw(). Call draw_circle() directly in _process() and no circle is drawn, and you get an error: "Drawing is only allowed inside this node's _draw()...". The update() in Godot 3 articles was renamed to queue_redraw() in 4.

In other engines: for checking states with shapes, this corresponds to Unity's Gizmos (OnDrawGizmos) or UE's Draw Debug. But Godot's _draw() is ordinary 2D drawing, so anything you want gone in the shipped game, you remove yourself.

Drawing coordinates (to_local())

Coordinates in _draw() are positions with the drawing node as the origin (local coordinates). Vector2.ZERO means that node's own position. To draw a circle around an enemy, the center is Vector2.ZERO.

Where people trip is drawing a line to another node's position. Say you pass the player's global_position (its position measured from the origin of the whole world) as the end point as-is. The line points to a spot offset by the drawing node's position (and if the node is rotated or scaled, it's off by that too). Convert the end point to a position relative to the drawing node with to_local() before passing it.

draw_line(Vector2.ZERO, to_local(player.global_position), Color.SKY_BLUE, 2.0)
A line passed global_position as-is points to the wrong spot, while a line converted with to_local() reaches the target

The Debug menu's displays (Visible Collision Shapes)

The Debug menu at the top of the editor lists displays the engine draws for you. The first one to use when investigating enemy AI is Visible Collision Shapes. It draws CollisionShape2D shapes as translucent overlays, including the ranges of Area2Ds, which report overlaps without pushing back. There are also Visible Paths for paths, and Visible Navigation and Visible Avoidance for navigation.

The checkboxes are read when the game starts. If you toggle one while running, stop and run again.

What the engine draws are "shapes the engine knows about," like collision. It doesn't know about your enemy's detection distance variable, where it's heading right now, or the name of its state. Those you draw yourself.

The engine draws the translucent collision circle, while you draw the outlined circle, the line to the destination, and the state label yourself

Overlay the two and you can compare on screen whether the value you drew matches the actual detection. If your own circle and the engine's collision circle don't overlap, either the variable or the detection is off.

Sponsored

A child node for drawing (DebugDraw)

Don't write the drawing code in the enemy's script. Split it out into a child node for drawing called DebugDraw.

One reason is draw order. What a parent draws in _draw() gets covered by a child Sprite2D drawn on top. Draw the circle on the enemy itself and the overlapping part is hidden behind the sprite. Put DebugDraw below Sprite2D in the Scene dock and, since it's drawn later, it lands on top of the sprite.

The other reason is how easily you can show and hide it. If the drawing is gathered in one node, switching its visible is all it takes to hide it. queue_redraw() is also called on DebugDraw itself, the node that has _draw(). Calling it on the enemy doesn't redraw DebugDraw. The export dialog has Export With Debug, which decides whether development features are included. In a game exported with it unchecked, OS.is_debug_build(), which returns whether this is a development build, is false. So in _ready(), remove the whole node. While running from the editor it's true, so the circle still appears.

With Sprite2D, Sight, and DebugDraw lined up under Enemy, DebugDraw is drawn on top and can be toggled with F3

Hands-On: Draw a Guard's Detection Distance, Destination, and State

A guard in a stealth game, a patrolling enemy in a top-down action game, a turret in tower defense—they all run on three things: "how far it notices," "where it's heading right now," and "what state it's in right now." You'll build an enemy that patrols and chases once it notices, then draw those three on screen. By overlaying the circle you draw on the collision circle, you'll find why the enemy doesn't notice.

A large circle and a translucent collision circle around the enemy, with PATROL above its head. The player is inside the large circle, but the line points to a flag

① What to place

Start with a new 2D project. Leave the screen size at the default 1152×648.

  • main.tscn: the root is a Node2D (named Main). Set it under Project Settings → Application → Run → Main Scene. Place Player and Enemy as children
  • Player: a CharacterBody2D (position (760, 184), directly above the enemy, a little way off). Add it to the player group in the Groups dock. Once it's in the group, the enemy can tell whether something is the player with is_in_group("player"). Children: a Sprite2D (icon.svg, Scale (0.25, 0.25)) and a CollisionShape2D (CircleShape2D, Radius 16)
  • Enemy: a Node2D (position (760, 324)). Children, in this order: a Sprite2D (icon.svg, Scale (0.5, 0.5)), Sight (an Area2D with a CollisionShape2D child (CircleShape2D, Radius 100)), and DebugDraw (a Node2D)
In main.tscn, Main holds Player in the player group, and Enemy with Sprite2D, Sight with radius 100, and DebugDraw

Change visual size only with the Sprite2D's Scale, and leave every other node at (1, 1). Before running, turn on Debug → Visible Collision Shapes.

② Put it together

The player just moves with the arrow keys.

# player.gd (attach to Player)
extends CharacterBody2D

func _physics_process(_delta: float) -> void:
	velocity = Input.get_vector("ui_left", "ui_right", "ui_up", "ui_down") * 200.0
	move_and_slide()

The enemy goes back and forth between two points on the left and right, and chases when the player enters Sight. The Sight Area2D emits body_entered when a body (such as a CharacterBody2D) overlaps it. The detection distance is meant to be 200, from detect_radius.

# enemy.gd (attach to Enemy)
class_name Enemy
extends Node2D

enum State { PATROL, CHASE }

@export var detect_radius := 200.0   # detection distance (supposedly)
@export var speed := 80.0

var state := State.PATROL
var target := Vector2.ZERO           # where it's heading right now (global coordinates)
var player: Node2D = null
var patrol_points: Array[Vector2] = []
var patrol_index := 0

@onready var sight: Area2D = $Sight

func _ready() -> void:
	patrol_points = [global_position + Vector2(-120, 0), global_position + Vector2(120, 0)]
	target = patrol_points[patrol_index]
	sight.body_entered.connect(_on_sight_body_entered)
	sight.body_exited.connect(_on_sight_body_exited)

func _physics_process(delta: float) -> void:
	if state == State.CHASE:
		target = player.global_position
	else:
		target = patrol_points[patrol_index]
		if global_position.distance_to(target) < 4.0:
			patrol_index = (patrol_index + 1) % patrol_points.size()
	global_position = global_position.move_toward(target, speed * delta)   # move toward target by at most speed * delta

func _on_sight_body_entered(body: Node2D) -> void:
	if body.is_in_group("player"):
		player = body
		state = State.CHASE

func _on_sight_body_exited(body: Node2D) -> void:
	if body == player:
		player = null
		state = State.PATROL

DebugDraw just reads the enemy's variables and draws them. class_name Enemy on the first line of enemy.gd lets you write Enemy as a type. The F3 used to show and hide it is a key chosen just for this article's hands-on.

# debug_draw.gd (attach to DebugDraw)
extends Node2D

@onready var enemy: Enemy = get_parent()

func _ready() -> void:
	if not OS.is_debug_build():
		queue_free()                   # remove it in the exported shipped game

func _process(_delta: float) -> void:
	queue_redraw()                     # reflect changes in destination and state in the display

func _unhandled_input(event: InputEvent) -> void:
	if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F3:
		visible = not visible          # toggle with F3

func _draw() -> void:
	var color := Color.ORANGE if enemy.state == Enemy.State.CHASE else Color.SKY_BLUE
	draw_circle(Vector2.ZERO, enemy.detect_radius, color, false, 2.0)   # detection distance (the variable's value)
	draw_line(Vector2.ZERO, to_local(enemy.target), color, 2.0)        # where it's heading right now
	draw_string(ThemeDB.fallback_font, Vector2(-40, -48), Enemy.State.keys()[enemy.state])   # pick the current name from the list of state names

③ Run it and check

Before the fix, the collision circle is smaller than the drawn circle and it stays PATROL. After the fix, the circles match and an orange line reaches toward the player

Run Main. Check three things.

First, the enemy goes back and forth toward the left and right patrol points. Around the enemy appear the circle you drew yourself (radius 200) and a smaller translucent circle (the Sight collision, radius 100). A small collision circle appears around the player too. Above the enemy's head is "PATROL", and the line points to the patrol point it's heading for right now. If you can't see the translucent circle, turn on Visible Collision Shapes and run again.

Next, look at where the player is. The player is inside the large circle from the start, yet the enemy keeps patrolling. Wherever the enemy is between left and right, the distance between the centers is 140 to 184. Even including the player's collision, it doesn't reach the small circle. The distance at which the enemy notices wasn't the 200 you drew. It was the 100 of the collision.

Click the game screen and move closer with the down arrow key. When the player's collision overlaps the small circle, it changes to "CHASE". The circle and line turn orange, and the line reaches toward the player. Keep running sideways and, since the player is faster, you shake it off and it goes back to "PATROL".

To finish, add one line at the top of _ready() in enemy.gd and run again.

	($Sight/CollisionShape2D.shape as CircleShape2D).radius = detect_radius   # match the collision circle to the circle being drawn

The center and radius of the drawn circle and the collision circle line up, and it switches to "CHASE" right after starting. If you place several enemies, duplicate the shape before this line (the reason is in the second row of Troubleshooting). The player also has a collision radius of 16, so the noticing threshold is 200 + 16 = 216 between the centers. The starting distance is 140, so you're already inside the range.

Two points.

  • Draw the value the detection actually reads: the drawn circle read detect_radius, while detection read the Sight circle. When they look at separate values, the circle you drew and the actual detection disagree. The one line you added makes the collision circle match detect_radius at startup, so the drawn value and the detection value are the same
  • Overlay it with the engine's display: looking only at the circle you drew, you'd never notice the mismatch. Overlaying it with the Visible Collision Shapes collision made the difference between the two circles visible. If you want to add a viewing angle and wall checks, Vision Detection continues from here
Sponsored

Troubleshooting

SymptomCauseFix
The circle is filled in and hides overlapping charactersdraw_circle() fills by defaultPass false as the fourth argument and the line width (such as 2.0) as the fifth
After duplicating an enemy, changing one's radius changed every enemy's reaction distanceDuplicated enemies share the same CircleShape2D (the shape data still points to the same single one even after duplicating the node)Before rewriting the radius, swap shape for a shape.duplicate() copy
draw_string() errors, or the text is in the wrong placeThe first argument needs a font. The position is the baseline (the line along the bottom of the text) at the text's left edgePass ThemeDB.fallback_font (the backup font the theme provides) as the font. To show it above the head, use a negative Y
Hiding it with F3 still leaves the translucent circleThe translucent circle is drawn by Visible Collision ShapesTurn it off in the Debug menu and run again

Bonus: Good to Know First

  • Want to see the circle while editing too?: put @tool (a marker that runs the script inside the editor too) at the top of a script, and _draw() can run in the editor too. This article's DebugDraw reads values from the enemy's script, so just adding one @tool line won't make it work. How to set it up is in Editor Plugin Basics
  • When the state flickers at the boundary: separate the noticing circle from the losing-track circle, and make the losing-track circle a little bigger. Draw both circles and you can see the width of the gap between them
  • When you want to see the navigation path: turn on debug_enabled on NavigationAgent2D and it draws the computed path as a line. How to use it is in NavigationAgent2D Basics

Summary

  • _draw() only runs when asked: what's drawn stays. If a line's end point or text values change, use queue_redraw()
  • Coordinates are relative to the drawing node: pass other nodes' positions through to_local()
  • Shapes the engine knows about are in the Debug menu: draw variables, destinations, and states yourself
  • Put the drawing node under the enemy as a child: it draws on top of the sprite, and showing and hiding it happens in one place
  • Draw the value the detection reads: overlay it with the engine's display to see mismatches

Start by drawing the detection distance circle. In your enemy AI, which values do you want to put on screen and check?

Read nextNext in the learning path
Basics

Complete Guide to Static Typing in GDScript: Better Performance and Fewer Bugs

Read this note
Godot Notes in this section104