I'm working on a top down shooter, and although the gun fires correctly when queue_free (after the bullet collides with something) is commented out, it doesn't otherwise.

extends Node3D

@export var speed: float = 30.0
@export var lifetime: float = 3.0
@export var damage: int = 10

func _ready() -> void:
	add_to_group("bullets")
	var timer = get_tree().create_timer(lifetime)
	timer.timeout.connect(queue_free)

func _process(delta: float) -> void:
	position += transform.basis.z * speed * delta

# Add this function to handle collisions
func _on_area_3d_body_entered(body: Node3D) -> void:
	if body.has_method("take_damage") and not body.is_in_group("player") and not body.is_in_group("bullets"):
		body.take_damage(damage)
	queue_free()

What exactly is the symptom?

Are there any error messages?

One possible problem is that queue_free could get called twice, since you're calling it both when the timer times out and when a collision occurs. That could be solved by using this instead of queue_free:

func destroy() -> void:
    if is_instance_valid(self):
        queue_free()

After coming back to this, figured the issue out: queue_free() (now destroy()) wasn't indented to match the if statement.