Trouble with firing bullets after implementing object pooling

Godot Version

4.3 stable Windows 11

Question

I’m making a bullet hell game, and to improve performance I attempted to implement object pooling. This is what my bullet scene tree looks like:
image
image

This is the script attached to every bullet:

extends CharacterBody2D

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

@export var type : String
@onready var colour : int
@onready var speed : float
@onready var accel : float
@onready var target_speed : float
@onready var angle : float
@onready var angular_velocity : float
@onready var lifespan : float
@onready var damage : float

@onready var hitbox : Area2D
@export var can_rotate : bool

func _ready():
	print(get_parent())
	deactivate()

func activate():
	$Sprite.frame = colour
	$"Sprite/Hitbox/CollisionShape2D".set("disabled", false)
	$AnimationPlayer.play(&"RESET")
	add_to_group("bullet")
	remove_from_group("bulletpool")
	if not lifespan == null :
		await(get_tree().create_timer(lifespan).timeout)
		play_death_animation()

func play_death_animation():
	$"Sprite/Hitbox/CollisionShape2D".set("disabled", true)
	$AnimationPlayer.play("despawn")

func deactivate():
	remove_from_group("bullet")
	add_to_group("bulletpool")
	BulletManager.bullet_pool[type].append(self)
	BulletManager.active_bullets[type].erase(self)
	global_position = Vector2(29999, 29999)
	speed = 0
	accel = 0
	target_speed = 0
	angle = 0
	angular_velocity = 0
	damage = 0
	velocity = Vector2(0,0)
	set_process_mode(PROCESS_MODE_DISABLED)

func move():
	speed = move_toward(speed, target_speed, accel)
	if not angular_velocity == 0 :
		angle += angular_velocity
	velocity = Vector2.from_angle(angle) * speed
	rotation = angle
	move_and_slide()


func _on_animation_player_animation_finished(despawn) -> void:
	deactivate()

This is the code in my main scene that instantiates the bullets when the game runs:

func instantiate_bullets():
	for i in 500 :
		for type in BulletManager.bullet_pool :
			var objShot = load(BulletManager.BULLET_DIRECTORY + type + ".tscn").instantiate()
			BulletManager.bullet_pool[type].append(objShot)
			get_tree().get_root().call_deferred("add_child", objShot)

This is the global BulletManager script:

extends Node

const BULLET_DIRECTORY = "res://Enemy/Bullets/"

var bullet_pool : Dictionary = {
	"ball_M": [],
	"knife": []
}

var active_bullets : Dictionary = {
	"ball_M": [],
	"knife": []
}

func _physics_process(delta):
	for i in active_bullets :
		for bullet in active_bullets[i] :
			if bullet.has_method("move") :
				bullet.move()

This is the function in my enemy class script to be called when I want to fire bullets:

func CreateShotA1(bullet_type : String, colour : int, pos : Vector2, speed : float, accel : float, target_speed : float, angle : float, angular_velocity : float, lifespan : float, dmg : float):
	
	var objShot = get_available_bullet(bullet_type)

	if not objShot == null :
		objShot.colour = colour
		objShot.global_position = pos
		objShot.speed = speed
		objShot.accel = accel
		objShot.target_speed = target_speed
		objShot.angle = angle
		objShot.angular_velocity = angular_velocity
		objShot.lifespan = lifespan
		objShot.damage = dmg
	
		objShot.set_process_mode(PROCESS_MODE_INHERIT)
		objShot.activate()
		BulletManager.active_bullets[bullet_type].append(objShot)
		BulletManager.bullet_pool[bullet_type].erase(objShot)
		return objShot

func get_available_bullet(bullet_type: String):
	var avaliable_bullet_id = BulletManager.bullet_pool[bullet_type].pop_back()
	if not BulletManager.active_bullets.get(bullet_type, false):
		BulletManager.active_bullets[bullet_type] = []
	BulletManager.active_bullets[bullet_type].append(avaliable_bullet_id)
	return avaliable_bullet_id

And this is an example of an enemy behaviour that calls the function to fire bullets:

func attack1():
	var howmany = clamp(roundi(WaveManager.difficulty / 10), 8, 24)
	var angle = 0.00
	for i in howmany :
		CreateShotA1("knife", 0, global_position+Vector2(35*cos(angle), 1*sin(angle)), 40, 0, 40, angle, 0, 5, damage)
		angle += TAU/howmany
		await(get_tree().create_timer(3/howmany).timeout)

The problem is, everytime an enemy fires a bullet, I get the error Cannot call method 'create_timer' on a null value pointing to the line await(get_tree().create_timer(lifespan).timeout) in the bullet script. I’ve checked the remote tab whenever I run my game and confirmed the bullets are, indeed, part of the scene tree.

What could be the problem here? I’ve already asked about this issue before (x), but have not figured out the problem. Apologies for the long post, but at this point I’m desperate to solve this issue. Any help is appreciated.

This may be a dumb question, but could it be because the parent node of objShot is the root and not the current scene? Also, is there a particular reason the root node has to be the one adding the child? This may be relevant since godot distinguishes root node and current scene.

Perhaps replacing that line of code by this could help, although it is dubitable:

get_tree().current_scene.call_deferred("add_child", objShot)

I replaced the line with your suggestion, but that didn’t work. However, I tried replacing it with get_tree().get_first_node_in_group("stage").call_deferred("add_child", objShot) and, well… now half of the time I get the same error, and the other half my bullets are properly fired, though they only show up for one frame, seemingly at random.

Could this have something to do with the main thread and the bullet instantiating thread not always going at the same pace?