How do I get my enemy to open the right doors?

Godot 4.5

If my enemy is chasing down the player, I want them to be able to open any doors that stand in between them and the player while also ignoring the doors that aren’t in their way. It’s easy for me to get the enemy to open doors but, I don’t know how to get the enemy to focus exclusively on doors that stand between it and the player. Do you have a solution to the problem? My door is an “Area3D” and here’s what the code for it looks like if it helps:

extends Area3D

var entered_the_area = false
var door_open = false
var door_anim_speed = 1
@export var unlocked = true
@export var locked_with_key = false
@export var door_key_index = 1
@onready var anim_player : AnimationPlayer = $%AnimationPlayer


# Called when the node enters the scene tree for the first time.
func _ready():
	body_entered.connect(_on_body_entered)
	body_exited.connect(_on_body_exited)

func _process(delta):
	if Input.is_action_just_pressed("Interact") and unlocked:
		if entered_the_area == true:
			door_animation()

func door_animation():
	if door_open == false:
		anim_player.play("Open Door",0,door_anim_speed)
		door_open = true
	elif door_open == true:
		anim_player.play_backwards("Open Door")
		anim_player.speed_scale = door_anim_speed
		door_open = false


func _on_body_entered(body):
	if body.name == "FPS_Controller":
		if locked_with_key and unlocked == false:
			if body.keys[door_key_index] == true:
				unlocked = true
				locked_with_key = false
		entered_the_area = true


func _on_body_exited(body):
	if body.name == "FPS_Controller":
		entered_the_area = false

Now here is the relevant code for the enemy AI:

# Enemy.gd
extends CharacterBody3D
class_name Enemy
@export_group("State Variables")
@export var patrol_speed := 2.0
@export var chase_speed := 4.0
@export var sight_range := 15.0
@export var obstacle_range := 5
@export var turn_angle_deg := 60.0
@export var aim_time := 1.5

@export_group("Behavior Toggles")
@export var can_idle: bool = true         # allow using idle state
@export var can_patrol: bool = true       # allow using patrol state
@export var can_chase: bool = true        # allow chasing player
@export var stand_ground: bool = false    # if true → enemy never moves, only shoots

@export_group("Weapon Variables")
@export var shoot_damage := 20
@export var firing_range = 150
@export var ammo_capacity: int = 30   # max ammo per mag
@export var current_ammo: int = 30    # starts full
@export var reload_time: float = 2.5  # length of reload (seconds)
@export var low_ammo_threshold: int = 5  # AI tries to reload early if safe


@onready var fsm: EnemyFSM = $%EnemyFSM
@onready var ray: RayCast3D = $%Ray
@onready var anim_player : AnimationPlayer = $%AnimationPlayer
@onready var nav_agent = $%NavigationAgent3D
@onready var Look_At_Mod3d : LookAtModifier3D = $%LookAtModifier3D

var player : CharacterBody3D = null
var gravity = 9.8
var speed = 3.5

var last_aim_time := -100.0
var peek_threshold := 3.0 # seconds before enemy "forgets" the last aim

# Enemy.gd additions (place near other exported variables)
@export var max_health: int = 100
var health: float = max_health

# Stagger gauge
@export_group("Stagger")
@export var stagger_threshold: int = 20   # when reached → trigger pain
var stagger_gauge: int = 0
@export var stagger_decay_delay: float = 2.0   # time of no damage before gauge starts decaying
@export var stagger_decay_rate: float = 6.0    # gauge points per second while decaying
var _time_since_last_damage: float = 0.0

@export var use_look_at_modifier := true

# --- Event flags consumed by the FSM (do not change directly in states) ---
var event_was_damaged: bool = false
var event_last_damage_amount: float = 0.0
var event_last_hit_position: Vector3 = Vector3.ZERO
var event_last_hit_hurtbox: Node = null
var event_last_attacker: Node = null

var event_should_enter_pain: bool = false
var event_should_die: bool = false

# Track last hit info
var last_hit_position: Vector3 = Vector3.ZERO
var last_hit_hurtbox: Node = null
var last_hit_attacker: Node = null

var _last_stagger_hurtbox: Hurtbox = null
var _last_death_hurtbox: Hurtbox = null

var just_reloaded: bool = false
var is_alerted: bool = false

# Pain animation map (optional)
var pain_anim_map = {}

func _ready():
	if Look_At_Mod3d.active == true:
		Look_At_Mod3d.active = false
	# Ensure ray won't hit the enemy itself:
	# (exclude_parent should be true by default, but add_exception is the most robust)
	ray.add_exception(self)
	ray.enabled = true

func _physics_process(delta: float) -> void:
	# existing FSM updates etc...
	# Stagger decay bookkeeping
	if health > 0:
		_time_since_last_damage += delta
		if _time_since_last_damage >= stagger_decay_delay and stagger_gauge > 0:
			stagger_gauge = max(0, stagger_gauge - int(stagger_decay_rate * delta))


# Called by Hurtbox to centralize damage handling
# Called by Hurtbox to centralize damage handling (no direct FSM changes)
func take_damage_from_hurtbox(amount: float, hurtbox: Node, hit_position: Vector3, attacker) -> void:
	if health <= 0:
		return

	health -= amount
	_time_since_last_damage = 0.0

	# store last hit info (used later by states)
	self.last_hit_position = hit_position
	self.last_hit_hurtbox = hurtbox
	self.last_hit_attacker = attacker

	# set event fields the FSM will consume
	event_was_damaged = true
	event_last_damage_amount = amount
	event_last_hit_position = hit_position
	event_last_hit_hurtbox = hurtbox
	event_last_attacker = attacker

	# remember the player if attacker is player-like
	if attacker != null and attacker.is_in_group("Player"):
		player = attacker

	# mark alerted; FSM/states can read this and act
	is_alerted = true

	# If health drops to zero or below, schedule death (but don't change state directly)
	if health <= 0:
		# store hurtbox for death state and set event flag
		self._last_death_hurtbox = hurtbox
		event_should_die = true
	SoundManager.emit_sound(global_position, 20.0, true, [self])

# Called by Hurtbox to add stagger (no direct FSM calls)
func add_stagger(amount: int, from_hurtbox: Hurtbox) -> void:
	if health <= 0:
		return  # prevent pain/other states after death

	stagger_gauge += amount
	_time_since_last_damage = 0.0

	# store last stagger hurtbox (for Pain state to read)
	self._last_stagger_hurtbox = from_hurtbox

	# If threshold reached, schedule pain via event flag (FSM will handle transition)
	if stagger_gauge >= stagger_threshold:
		stagger_gauge = 0
		event_should_enter_pain = true


# Called when health <= 0 (non-blocking for FSM)
func die(hurtbox) -> void:
	# Stop movement/pathfinding immediately (keeps behavior consistent)
	if nav_agent:
		nav_agent.set_physics_process(false)
		nav_agent.set_target_position(global_position)  # clears any path

	# Store final hurtbox
	self._last_death_hurtbox = hurtbox

	# Do NOT call fsm.change_state here. Instead set event flag and let the FSM consume it.
	event_should_die = true


# Simple damage helper (try to call target.apply_damage if present)
func apply_damage_to(target, amount: int) -> void:
	if target == null:
		return
	if target.has_method("apply_damage"):
		target.apply_damage(amount)
	elif "health" in target and target.has_variable("health"):
		target.health -= amount
	else:
		# fallback: print so you can hook damage manually
		print("Enemy tried to apply damage but target has no damage handler.")

func on_hear_sound(source_position: Vector3, distance: float) -> void:
	
	if is_alerted:
		return

	print("Enemy heard something at:", source_position)
	is_alerted = true
	if player == null:
		player = get_tree().get_first_node_in_group("Player")
		fsm.change_state("Enemy_Alerted")
	elif player == get_tree().get_first_node_in_group("Player"):
		fsm.change_state("Enemy_Alerted")

	# Optional: store direction to face that spot
	if not is_instance_valid(player):
		player = null  # clear if lost
		look_at(Vector3(source_position.x, global_position.y, source_position.z), Vector3.UP)

Here’s the Enemy’s FSM:

# EnemyFSM.gd
extends FiniteStateMachine
class_name EnemyFSM

@export var fsm_enabled: bool = true
@export var debug_force_state: String = "" # Leave empty for normal operation

var enemy_states: Dictionary = {}        # Key: state name, Value: EnemyState instance
var _enemy_current_state: EnemyState = null
var previous_state : String = ""
var enemy: Enemy = null
var fsm_ready = false #This prevents an error when it comes to the previous sta

func _ready():
	enemy = get_parent() as Enemy

	# --- Auto-register all EnemyState children ---
	for child in get_children():
		if child is EnemyState:
			var state_name = child.name
			enemy_states[state_name] = child
			child.fsm = self
			child.enemy = enemy
	
	# Start with idle state if present
	if enemy_states.has("Enemy_Idle"):
		change_state("Enemy_Idle")


func change_state(state_name: String) -> void:
	if not enemy_states.has(state_name):
		push_error("EnemyFSM: State '%s' does not exist!" % state_name)
		return
	
	if _enemy_current_state != null:
		_enemy_current_state.on_state_exit()
	
	if fsm_ready == true:
		previous_state = String(_enemy_current_state.name)
	else:
		fsm_ready = true
	_enemy_current_state = enemy_states[state_name]
	_enemy_current_state.on_state_enter()

func _physics_process(delta: float) -> void:
	# --- Event processing: let the FSM own the decisions on damage/stagger/death/alert ---
	if enemy != null:
		# Death has absolute priority
		if enemy.event_should_die:
			# Ensure we only handle once
			enemy.event_should_die = false
			change_state("Enemy_Dead")
			# early return — dead state will run this tick
			return

		# Pain/stagger has high priority (but respect current state's interrupt policy)
		if enemy.event_should_enter_pain:
			# consume flag now so we don't re-enter repeatedly
			enemy.event_should_enter_pain = false
			# If current state can be interrupted by pain, or if we're not already in pain/dead
			if _enemy_current_state == null or _enemy_current_state.name != "Enemy_Pain":
				# optional: check priority on base state (if your Pain state has a config)
				change_state("Enemy_Pain")
				return

		# Damage/alert event: e.g., wake idle enemies into alerted (preserve old behaviour)
		if enemy.event_was_damaged:
			enemy.event_was_damaged = false
			# If we were idle, become alerted like before
			if previous_state == "Enemy_Idle" or _enemy_current_state == null:
				change_state("Enemy_Alerted")
				return
			# otherwise let the current state respond to being damaged if it wants

	
	if not fsm_enabled:
		return
	
	# Debug mode: force a specific state
	if debug_force_state != "":
		if _enemy_current_state == null or _enemy_current_state.name != debug_force_state:
			change_state(debug_force_state)
		if _enemy_current_state:
			_enemy_current_state.on_state_update(delta)
		return
	
	# Normal FSM behavior
	if _enemy_current_state:
		_enemy_current_state.on_state_update(delta)

Now here’s the enemy’s chase script:

extends EnemyState
class_name Enemy_Chase_State

@export var chase_speed := 3.0
@export var running_anim_speed := 3

@export var max_chase_time := 5.0   # Time (seconds) before enemy forces combat
@export var long_range_factor := 0.75 # Portion of firing_range considered "long range"

var chase_timer := 0.0

func on_state_enter():
	chase_timer = 0.0

func move_here(location: Vector3, delta: float) -> void:
	target_position(location)
	follow_path(delta)
	gravity_force(delta)

func on_state_update(delta: float) -> void:
	# Respect toggles
	if enemy.stand_ground or not enemy.can_chase:
		fsm.change_state("Enemy_Shoot")
		return
	enemy.anim_player.play("Uzi_Run", 0, running_anim_speed)

	if not is_instance_valid(enemy.player):
		fsm.change_state("Enemy_Idle")
		return

	# Chase timer
	chase_timer += delta

	# Pathfinding pursuit
	move_here(enemy.player.global_transform.origin, delta)

	var dist = dist_2_player()

	# --- Force combat if chasing too long ---
	if chase_timer >= max_chase_time and player_spotted():
		fsm.change_state("Enemy_Shoot")
		return

# --- Adaptive aggression: long-range logic ---
	if player_spotted() and dist <= enemy.firing_range:
		if should_reload():
			fsm.change_state("Enemy_Reload")
			return

		var range_limit = enemy.firing_range * long_range_factor
		if dist > range_limit:
			fsm.change_state("Enemy_Shoot")
		else:
			fsm.change_state("Enemy_Alerted")

Now here’s the enemy’s state base class script:

extends State
class_name EnemyState

@onready var enemy : Enemy = parent  # Assumes parent is the Enemy (CharacterBody3D)


func _ready():
	assert(enemy is CharacterBody3D)


func player_spotted():
	if parent.ray.is_colliding():
		var c = parent.ray.get_collider()
		if c and c.is_in_group("Player"):
			if parent.player == null:
				parent.player = c
			return true
		else:
			return false

func reset_ray_rotation():
	if parent.ray:
		parent.ray.rotation_degrees.y = 0
		parent.ray.position.y = 1.641


func dist_2_player() -> float:
	if is_instance_valid(parent.player):
		var self_pos = parent.global_transform.origin
		var player_pos = parent.player.global_transform.origin
		# Ignore height (Y-axis), compare only XZ plane
		self_pos.y = 0.0
		player_pos.y = 0.0
		return self_pos.distance_to(player_pos)
	return INF

func gravity_force(delta: float):
	if not parent.is_on_floor():
		# Apply gravity normally
		parent.velocity.y -= parent.gravity * delta
	else:
		# Reset vertical velocity when grounded
		parent.velocity.y = 0.0

func cone_of_vision(delta, sweep_amount = 100,cone_range = 45):
	if (parent.ray.rotation_degrees.y >= -cone_range) and (parent.ray.rotation_degrees.y < cone_range):
		parent.ray.rotation_degrees.y += sweep_amount * delta
	else:
		parent.ray.rotation_degrees.y = -cone_range

func follow_path(delta: float):
	var next_location = parent.nav_agent.get_next_path_position()
	var current_location = parent.global_transform.origin
	
	# Horizontal steering only
	var desired = (next_location - current_location).normalized() * parent.speed
	desired.y = 0.0

	# Smooth steering on XZ plane
	var current_h = Vector3(parent.velocity.x, 0, parent.velocity.z)
	var target_h = current_h.move_toward(Vector3(desired.x, 0, desired.z), 8 * delta)

	# Apply new horizontal
	parent.velocity.x = target_h.x
	parent.velocity.z = target_h.z

	# Let gravity_force() handle velocity.y
	parent.move_and_slide()

	# Smooth rotation instead of snapping
	if target_h.length() > 0.1:
		var target_dir = Vector3(target_h.x, 0, target_h.z).normalized()
		var current_dir = -parent.global_transform.basis.z
		var lerped_dir = current_dir.lerp(target_dir, 6 * delta).normalized()
		
		parent.look_at(parent.global_position + lerped_dir, Vector3.UP)


func target_position(target): #Set target for pathfinding!
	parent.nav_agent.target_position = target

# Simple forward move helper (call from states)
func move_forward(speed: float, delta: float) -> void:
	parent.velocity = -parent.global_transform.basis.z * speed
	parent.move_and_slide()

func needs_reload() -> bool:
	return parent.current_ammo <= 0

func should_reload() -> bool:
	return parent.current_ammo <= parent.low_ammo_threshold
	
func face_point(global_point: Vector3) -> void:
	#look_at(global_point, Vector3.UP)
	parent.look_at(Vector3(global_point.x, parent.global_position.y, global_point.z), Vector3.UP)

func vertical_scanning(delta,scanning_amount =25):
	if (parent.ray.position.y >= 0) and (parent.ray.position.y < 2.5):
		parent.ray.position.y += scanning_amount * delta 
	else:
		parent.ray.position.y = 0

func avoid_obstacles():
	parent.ray.force_raycast_update()# Update raycast to check for obstacles in front
	if parent.ray.is_colliding():
		var collision_point = parent.ray.get_collision_point()
		var dist = parent.global_transform.origin.distance_to(collision_point)
		if dist <= parent.obstacle_range:
			parent.rotate_y(deg_to_rad(parent.turn_angle_deg if parent.ray.rotation_degrees.y < 0 else -parent.turn_angle_deg))

# --- LookAtModifier3D helpers ---
func set_look_at_target(active: bool, target: Node = null) -> void:
	if not ("Look_At_Mod3d" in enemy) or not enemy.use_look_at_modifier:
		return  # This enemy type doesn't support LookAtModifier3D
	
	if not is_instance_valid(enemy.Look_At_Mod3d):
		return  # No modifier node found

	if active:
		if target == null and is_instance_valid(enemy.player):
			target = enemy.player
		if is_instance_valid(target):
			enemy.Look_At_Mod3d.set_target_node(target.get_path())
			enemy.Look_At_Mod3d.active = true
	else:
		enemy.Look_At_Mod3d.active = false

There’s more that I can share with you but, none of it is relevant to the problem at hand; that being the problem of the enemy AI opening the wrong doors while chasing done the player. You might notice that there’s no solution implemented for the enemy to open the doors but, that’s because I deleted my current project and returned to using a pack up version of it that I can’t show you; it was a mess.
I obviously can’t just let the enemy open doors when it goes into their Area3D when they’re chasing the player, as they’ll be able to accidentially open doors that aren’t even in their way.

I’ve tried a solution that involved raycast with code but, it’s a convoluted mess. It would check if it could “spot” the enemy, check if it could “spot” the player and then check if they enemy and player could “spot” each other before allowing the enemy to open the door.
The idea behind the solution, is that it would confirm that the enemy is approaching a door that the player is certainly behind. As you can imagine, my attempt at a solution had a lot of problems.
Sometimes it would “work” but, othertimes the raycast would just hit objects that the enemy can easily move around like a rail on a stairs that the enemy could easily move up as they approached the door the player was behind; this is the type of problem that I’ve encountered the most; this would have prevent the door from “spotting” the enemy.
There’s also under problems that I didn’t run into but, I probably would have. For examine, the door could also have a problem “spotting” the player too if the player where to just go around a corner of some wall.
Out of desperation, I’ve tried to see if ChatGPT could help me to find a solution to this problem but it didn’t help me at all. After being stuck with it’s updates that weren’t fixing thep problem, I decided to delete the current project and go for a back up. Luckily for me, too much progress have not been lost.
So now out of desperation, I’m back here.:sweat_smile:
What’s frustrating about the problem is that it’s one that many older videogames have solved yet, I’m having a difficult time finding any online tutorials that address this very issue. This is probably my fault but, I don’t know where to start when it comes to searching for articles,blogs, pdfs or any book that address this problem. I have found two videos discussing the topic but, they’re for Unity and Unreal; I’ll link them right here:


Unfortunately for me, I don’t have the skills necessarily to translate the information into a solution for what I’ve got in the Godot3d Engine. Can you tell me what the solution is or where to find it? Can you point me to any written resouce that addresses this issue specifically? As long as it’s written, it would be an issue for me that another programming language is being used.

Hi.

That is a lot of code to post. I would just say that if I were doing this, and this is pretty broad stroke, I would use something like a NavigationAgent2D to see if a path to the enemy is possible, but ignoring any doors the enemy can open. So openable doors are not an obstacle to the Nav agent.

If a path is found, start moving along it of course. Then add in the enemy code that if a door is detected, open it. I would spot the doors using a short ray cast along the line of travel, and have the enemy pause, open the door, and continue along the path. The chances of hitting any unintended doors is either very slim or impossible if the ray cast is short enough.

You might, depending on your set up, just have the enemy look for a collision with a door, then pause and open the collided-with door.

Anyway, apart from the nav agent set up, this should be fairly straight forward. Hope you get your issue fixed and that this may have helped in some way.

1 Like