How to structure a decent enemy ai

Godot Version

4.6.3

Question

Are there any sources that I can go to see how to structure an advanced enemy ai and/boss fight for a 2d platformer or will I have to tough it out? (still fairly new to Godot)

Start by making a simple enemy first.

so how do I fix " ‘global_position’ on a base object of type ‘null instance’ " for this code to work?

full error: BossPassiveState._physics_update: Invalid access to property or key ‘global_position’ on a base object of type ‘null instance’.

it extends from a parent class that extends from refcounted

var player: PlayerController

func _enter(_state_name: String = "") -> void:
	player = enemy.get_tree().get_first_node_in_group("PlayerController")
	enemy.movement.set_rand_values()

func _update(_delta: float) -> void:
	if enemy.movement.move_timer > 0:
		enemy.movement.move_timer -= _delta
	else: 
		enemy.movement.set_rand_values()

func _physics_update(_delta: float) -> void:
	var direction : int = floori(player.global_position.x - enemy.global_position.x)
	enemy.movement.enemy_movement()
	
	if enemy.movement.direction != 0:
		enemy.animations.play("walk")
	else:
		enemy.animations.play("idle")
	
	if direction < 30:
		state_machine.change_state("pursuing")
	

You make sure that the reference in question is properly initialized. The error means it’s not, i.e. its value is null.

I’ve been at this for a while now and I can’t get the enemy to follow the player, I can get it to wander but not follow the player

tried:

	for overlapping_bodies in enemy.detection.get_overlapping_bodies():
		var distance := overlapping_bodies.global_position - enemy.global_position
		
		if distance.length() > 25:
			enemy.velocity = distance.normalized() * move_speed
			print("following")
		else :
			enemy.velocity = Vector2()

this one gives an error every time (even when triggering from collisions):

@onready var player: PlayerController = enemy.get_tree().get_first_node_in_group("player")


func enemy_pursuit_movement() -> void:
	var move_speed := move_values.move_speed

	var distance := player.global_position - enemy.global_position

	if distance.length() > 25:
		enemy.velocity = distance.normalized() * move_speed
	else :
		enemy.velocity = Vector2()

I just don’t know what to do

Try to get the player reference inside enemy_pursuit_movement() instead of just initializing it @onready

says the same error:

EnemyMovementComponent.enemy_pursuit_movement: Invalid access to property or key ‘global_position’ on a base object of type ‘null instance’.

but the game scene loads, player controller works and everything so why can’t it get the global position?

Determine which variable is null by printing all relevant variables, and then take care to assign the wanted reference to it before using it.

fixed it was how I declared distance

the fix:

var distance = player.global_position - enemy.global_position

before:

var distance : Vector2 = player.global_position - enemy.global_position

Isn’t distance supposed to be a scalar (single value) and not a vector?

I named I distance instead of direction because I already declared it globally for the wander phase, more likely gonna remove the global declaration for consistency

very good idea is to make diagram about what enemy does or trying to think in your enemy’s shoes

e.g, let’s say we have a sentry gun, as sentry gun you can say:

  • my goal is to shoot player
  • player need to be close and i need to look at him if i want to shoot
  • therefore i’ll check if player is near me
  • and if he is i’ll rotate to player
  • and if i’m rotated in the direction of player ± 5 degre, i’ll shoot

see, based off that you can pretty much build the code, all of loops, checks, ect.

Building on this, you might want to try commenting sections in your code to explain what is happening. Some devs are against this, but when you are learning it is an excellent way to keep track of what is happening.

func attack(enemy) -> void: 
  # Check if enemy is close enough
  # Look at enemy 
  # Shoot

here’s a current visual of my code for the enemy for better judgement

Enemy Controller (the character body 2d)

class_name BossController extends CharacterBody2D

@onready var body: Node2D = %Body
@onready var animations: AnimationPlayer = %EnemyAnimations
@onready var detection: Area2D = %Detection
@onready var visionbox: CollisionShape2D = %Visionbox
@onready var detection_ray: RayCast2D = %PlayerDetection

@export var health : HealthComponent
@export var movement: EnemyMovementComponent
@export var state_machine : StateMachine
@export var detection_values: DetectionValues

var has_detected_player : bool = false


func _ready() -> void:

	set_up_states()

func _process(delta: float) -> void:
	state_machine.update_process(delta)

func _physics_process(delta: float) -> void:
	state_machine.update_physics(delta)
	movement.update_gravity(delta)
	set_sprite_direction()
	ray_player_detection()
	move_and_slide()

func set_sprite_direction() -> void:
	if velocity.x > 0:
		body.scale.x = 1.0
	elif velocity.x < 0:
		body.scale.x = -1.0

func get_sprite_direction() -> float:
	return body.scale.x

func set_up_states() -> void:
	state_machine.add_state("passive", BossPassiveState.new(self, state_machine))
	state_machine.add_state("pursuing", BossPursuingState.new(self, state_machine))

	state_machine.starting_state("passive")

func ray_player_detection() -> void:
	var player : PlayerController = get_tree().get_first_node_in_group("player")
	
	if detection_ray.enabled:
		detection_ray.look_at(player.global_position)
	else:
		detection_ray.target_position.x = 110.0
		detection_ray.target_position.y = 0.0
	
	if detection_ray.is_colliding() and detection_ray.get_collider() == player:
		has_detected_player = true
	else:
		has_detected_player = false


func _on_detection_body_entered(_body: Node2D) -> void:
	visionbox.modulate = Color.RED
	detection_ray.enabled = true

func _on_detection_body_exited(_body: Node2D) -> void:
	visionbox.modulate = Color.WHITE
	detection_ray.enabled = false

The enemy passive/wander state (Bossstate extends from State which is refcounted):

class_name BossPassiveState extends BossState

func _enter(_state_name: String = "") -> void:
	pass

func _exit() -> void:
	enemy.movement.move_timer = 3

func _update(_delta: float) -> void:
	if enemy.movement.move_timer <= 0 :
		enemy.movement.set_rand_values()
	else:
		enemy.movement.move_timer -= _delta


func _physics_update(_delta: float) -> void:
	if enemy.movement.move_timer > 0:
		enemy.movement.enemy_movement()
	
	print(str(enemy.movement.move_timer))
	
	
	if enemy.movement.direction != 0:
		enemy.animations.play("walk")
	else:
		enemy.animations.play("idle")
	
	if enemy.has_detected_player:
			state_machine.change_state("pursuing")

the enemy pursuing state:

class_name BossPursuingState extends BossState

func _enter(_state_name: String = "") -> void:
	pass

func _exit() -> void:
	enemy.velocity = Vector2.ZERO
	enemy.movement.direction = 0

func _physics_update(_delta: float) -> void:
	enemy.movement.enemy_pursuit_movement()
	
	if enemy.velocity.x:
		enemy.animations.play("walk")
	else :
		enemy.animations.play("idle")

	if enemy.has_detected_player == false:
		state_machine.change_state("passive")

enemy_pursuit_movement function:

func enemy_pursuit_movement() -> void:
	var move_speed := move_values.move_speed
	var player := get_tree().get_first_node_in_group("player")
	direction_to_player = player.global_position.x - enemy.global_position.x
	#print("Player: " + str(player.global_position))
	#print("enemy: " + str(enemy.global_position))
#
	if direction_to_player > 25:
		enemy.velocity.x = (move_speed * (direction_to_player/direction_to_player))
	else:
		enemy.velocity = Vector2()

enemy basic movement function and set_rand_values (set_rand_values definitely could use a better name)

func set_rand_values() -> void:
	direction = randi_range(-1, 1)
	move_timer = randi_range(3, 5)

func enemy_movement() -> void:
	var move_speed := move_values.move_speed
	
	if enemy:
		enemy.velocity.x = move_speed * direction