Help with NPC behavior

Godot Version

v4.5.stable

Question

I’m making a 2.5D shooter in Godot where enemies rotate toward the player. The game is fully 3D, but I only want the enemy to rotate horizontally on the Y axis.

The problem is that the enemy rotates correctly from some directions, especially when the player is behind or to the side, but when the player moves in front of the enemy, the rotation suddenly flips by 180 degrees. It looks like the enemy instantly thinks the opposite direction is the correct forward direction.

At one point I partially fixed it, but the rotation still flipped at certain positions. The behavior makes me think there could be an issue with forward vectors, angle wrapping, or local vs global rotation calculations.

My expected behavior is for the enemy to smoothly and consistently face the player from all directions without sudden flipping or snapping.


Things I already tried

  • look_at()

  • rotating only the Y axis

  • changing between local/global rotation

  • clamping rotation

  • using direction vectors manually

The issue still happens.

Pls note im new to Godot and i dont know a lot this i heavily used AI since i could not find tutorials although i coded the PATROL and CHASE state myself

extends CharacterBody3D


var player_in_range := false
var player_target : Node3D = null

# STATES
enum State {
	PATROL,
	CHASE,
	AIM,
	SHOOT,
	DEAD
}

var current_state = State.PATROL


# MOVEMENT
@export var speed: float = 2.5
@export var chase_speed: float = 4.0
@export var timer: float = 0.6
@export var dash_rest: float = 2.0
@export var edge_detection := true

var turning := false
var direction := Vector3(1,0,0)
@onready var model = $MeshInstance3D
# COMBAT
@export var can_shoot := true
@export var attack_range := 5.0
@export var retreat_range := 3.0

@export var shoot_cooldown := 0.7

# COMPONENTS
@onready var health_component = $HealthComponent

@onready var player = get_tree().get_first_node_in_group("player")

# WEAPONS
@onready var gun = $EnemyGun
@onready var muzzle = $EnemyGun/EnemyTarget
@onready var shoot_ray = $EnemyGun/EnemyTarget

# EDGE DETECTION
@onready var edge_ray = $RayCast3D

# BULLET
var bullet := preload("res://bullet.tscn")


func _ready():

	add_to_group("damageable")

	if health_component == null:

		print("ERROR: HealthComponent missing")

		return

	health_component.died.connect(destroy)

	health_component.dash.connect(dash)

	print("Enemy Ready")


# MAIN LOOP
func _physics_process(delta: float) -> void:

	# GRAVITY
	if not is_on_floor():

		velocity += get_gravity() * delta

	# STATES
	match current_state:

		State.PATROL:

			state_patrol()

		State.CHASE:

			state_chase()

		State.AIM:

			state_aim(delta)

		State.SHOOT:

			state_shoot()

		State.DEAD:

			pass
	move_and_slide()


# PATROL
func state_patrol():

	velocity.x = speed * direction.x

	# WALL TURN
	if is_on_wall()and turning == false:
		turn_around()

	# EDGE TURN
	if edge_detection:

		if not edge_ray.is_colliding() and is_on_floor() and turning != true:

			turn_around()

	if player_in_range:

		current_state = State.CHASE
# CHASE
func state_chase():

	if !is_instance_valid(player_target):

		current_state = State.PATROL

		return

	var distance = global_position.distance_to(player_target.global_position)
	var dir = (player_target.global_position - global_position).normalized()

	face_player()

	# MOVE CLOSER
	if distance > attack_range:

		velocity.x = dir.x * chase_speed

	# BACK AWAY
	elif distance < retreat_range:

		velocity.x = -dir.x * chase_speed

	# PERFECT RANGE
	else:

		velocity.x = 0

		current_state = State.AIM

func face_player():

	if !is_instance_valid(player_target):
		return

	var dir = player_target.global_position - global_position

	if dir.x > 0:

		model.scale.z = 1

	else:

		model.scale.z = -1

# AIM
func state_aim(delta):

	if player == null:

		current_state = State.PATROL

		return

	velocity.x = 0

	aim_gun(delta)

	if has_line_of_sight():

		current_state = State.SHOOT

	# player too far again
	var distance = global_position.distance_to(
		player.global_position
	)

	if distance > attack_range + 2:

		current_state = State.CHASE


# SHOOT
func state_shoot():

	if not can_shoot:

		current_state = State.AIM

		return

	shoot()

	current_state = State.AIM


# AIM GUN
func aim_gun(delta):

	if !is_instance_valid(player_target):
		return

	var dir = (
		player_target.global_position
		- gun.global_position
	)

	var target_angle = atan2(dir.y, dir.x)

	gun.rotation.z = lerp_angle(
		gun.rotation.z,
		target_angle,
		6.0 * delta
	)


# SHOOT
func shoot():

	can_shoot = false

	var instance = bullet.instantiate()

	get_tree().current_scene.add_child(instance)

	instance.global_position = muzzle.global_position

	instance.global_transform.basis = muzzle.global_transform.basis
	print("Enemy Shot")

	reset_shoot()


func reset_shoot():

	await get_tree().create_timer(
		shoot_cooldown
	).timeout

	can_shoot = true


# LINE OF SIGHT
func has_line_of_sight() -> bool:

	shoot_ray.force_raycast_update()

	if shoot_ray.is_colliding():

		var collider = shoot_ray.get_collider()

		if collider.is_in_group("player"):
			player_in_range = true
			
			player_target = player

			current_state = State.CHASE

			print("PLAYER DETECTED")
			return true

	return false





# PLAYER DETECTION
func can_see_player() -> bool:
	current_state = State.CHASE
	return player_in_range


# TURN
func turn_around():

	turning = true

	var old_dir = direction

	direction = Vector3.ZERO

	var tween = create_tween()

	tween.tween_property(
		self,
		"rotation_degrees",
		Vector3(0,180,0),
		timer
	).as_relative()

	await get_tree().create_timer(timer).timeout

	direction.x = old_dir.x * -1

	turning = false


# DASH
func dash():

	print("Critical Health")

	speed = 6.0

	await get_tree().create_timer(
		dash_rest
	).timeout

	speed = 2.5


# DESTROY
func destroy():

	current_state = State.DEAD

	print("Enemy Died")

	queue_free()

func _on_area_detection_body_entered(body):
	if body.is_in_group("player") and shoot_ray.is_colliding():

		player_in_range = true

		player_target = body

		current_state = State.CHASE

		print("PLAYER DETECTED")
		
func _on_area_detection_body_exited(body):

	if body == player_target:

		player_in_range = false

		player_target = null
		await get_tree().create_timer(1).timeout
		current_state = State.PATROL

		print("PLAYER LOST")

# STOMP DAMAGE
func _on_top_checker_body_entered(body):

	if body.has_method("bounce"):

		body.bounce()

	health_component.damage(20)

One thing you could do is to only include the relevant parts of the script. You will get better help if you avoid pasting in loads of unnecessary code. I for one could not be bothered to read through it all to try to find the problematic part.

You’ve verified the face_player actually runs? I’d add in a print statement there to just confirm the interaction with line of sight works as expected.

at aim_gun(delta), you’re rotating the gun in local space. I suggest you transform dir to the gun’s local space or use gun.global_rotation.z instead.

I think its this part. Apologies for the unnecessary code I was just overwhelmed by the problem

Noted i’m new to this thanks for the tip

I had a look at the short part you shared and at the longer full script, but i really couldn’t figure out what is wrong.

Myself, though, i would try with .look_at() and use that alongside whatever you need for custom behaviour. .look_at() shouldn’t snap or flip around super fast at weird angles, like going negative 365 from 370 to 5, instead of adding 15, or whatever is happening here that causes the weird behaviour you mention.

I would also put aside some time to removing possibly confusing parts of the code. Right now you have states where you both change state if some condition has been met and continue with the state logic if the condition has not been met. It just makes it more difficult to follow along with what is going on and i imagine it will be more difficult for you to expand upon than if it was a bit separated. Lets say you have a function like set_state() that you call in process. Check the conditions there and then set the state. Then keep your state behavior functions below it and only let them manage the actual behavior. So 1. set state, 2. apply state. It will be easier for you to work with, read and understand, and same goes for most people who will read your code. Instead of “ok, some weird issue with setting states is happening, lets check inside every state behavior function what is going on”, you can just look it up in one function and not have any of it clutter your other code when trying to solve issues with the state behavior.

I spent i considerable amount of time debugging and ironing out the code. I didnt describe the problem properly it only worked well from one side after looking at the dir i noticed the problem only arose when it was negative so i used abs

var dir = abs(player_target.global_position - gun.global_position)

that ended up fixing it thanks a ton for your help