Design/architecture for upgrades with function callbacks in Godot?

Godot Version

Godot 4.4

Question

Hey everyone !

I’m working on a top-down shooter with an upgrade system that’s giving me some headaches. I’d appreciate advice on a cleaner architecture.

Current Implementation

My upgrade system uses callbacks that are stored in arrays in the ShootingManager and passed to bullet instances:

  1. Base Upgrade Class:
class_name Upgrade
extends Resource

@export var upgrade_name: String = "Base Upgrade"
@export var upgrade_description: String = "Base upgrade description"

func on_apply_amelioration(shooting_manager : ShootingManager, health_manager : HealthManager):
    pass
  1. Bullet Function Arrays:
# Function collections for shooting and bullet events
var bullet_on_spawn_functions = []
var bullet_on_hit_functions = []
var bullet_on_bounce_functions = []
var bullet_on_expiry_functions = []

Upgrade Flow Explained

Here’s how the flow works in my current system:

  1. When an upgrade is applied, it calls on_apply_amelioration() which:

    • Adds callback functions to the ShootingManager’s function arrays
  2. When a player shoots:

    • shooting_manager.shoot() creates a bullet
    • The bullet is initialized with the function arrays for different events
  3. During bullet lifecycle:

    • Callbacks from on_spawn_functions run when bullets are created
    • Callbacks from on_hit_functions run when bullets hit enemies

Example: Splittig Bullets Upgrade

The Explosive Bullets upgrade demonstrates the problem:

func on_apply_amelioration(shooting_manager: ShootingManager, health_manager: HealthManager):
    var explode_func = func(bullet):
        print("explode")
        for i in range(6):
            var angle = i * (2 * PI / 6)
            var direction = Vector2(cos(angle), sin(angle))
            var new_bullet = shooting_manager.bullet_scene.instantiate()
            bullet.get_tree().root.add_child(new_bullet)
    
    # Add explosion function to bullet hit events
    shooting_manager.bullet_on_hit_functions.append(explode_func)

In my bullet script, when a hit is detected:

func _on_area_entered(hurtbox: HurtBox) -> void:
    # ... existing collision handling ...
        spawn_effects()
        print("hit")
        for funct in on_hit_functions:
            funct.call(self)  # This calls explode_func when bullet hits
        queue_free()
    # ... more code ...

The Problem

The error occurs because:

  1. Bullet collides with an enemy during physics processing
  2. _on_area_entered is called
  3. The explode_func tries to create and add new bullets to the scene tree
  4. This happens during the physics step when queries are being flushed
  5. Godot throws an error:
E 0:00:04:409   explosive_bullets_upgrade.gd:21 @ <anonymous lambda>(): Can't change this state while flushing queries. Use call_deferred() or set_deferred() to change monitoring state instead.
  <Erreur C++>  Condition "body->get_space() && flushing_queries" is true.
  <Source C++>  modules/godot_physics_2d/godot_physics_server_2d.cpp:663 @ body_set_shape_as_one_way_collision()
  <Pile des appels>explosive_bullets_upgrade.gd:21 @ <anonymous lambda>()
                bullet.gd:104 @ _on_area_entered()

I could fix this specific case with call_deferred, but I feel like the whole design is problematic. Passing functions arrays like this seems wrong and making new upgrades will probably be as hard to debug as this one.
Also physics timing issues seems hard to manage.

I’m looking for a more robust architecture for upgrades that modify bullet behavior an patterns for organizing complex upgrade interactions like this. It seems really hard to do with Godot.

Has anyone implemented a similar system that works well? Any design patterns that would work better for this kind of feature?

Thank you for any insights!

You could make the signal itself deferred so you don’t have to call defer all the function calls, but I don’t know if that’s what you’re looking for.