You’ve likely heard the phrase “composition is better than inheritance,” but how do you apply this principle effectively in Godot? More importantly, how do you implement it without creating a tangled web of dependencies, often referred to as spaghetti code?

The General Approach

In many tutorials, you might see a pattern where a node contains specific behaviour that is then called from a parent or related nodes. While this can increase flexibility, it also leads to a higher degree of coupling, making nodes tightly dependent on each other.

class_name Player
extends Node2D

@onready var health: Node = $Health

func apply_damage(amount: int) -> void:
    health.decrease_health(amount)

func heal(amount: int) -> void:
    health.increase_health(amount)

func get_health() -> int:
    return health.get_health()
class_name Health
extends Node

@export var max_health: int = 100
var current_health: int

func _ready():
    current_health = max_health

func decrease_health(amount: int) -> void:
    current_health -= amount
    current_health = clamp(current_health, 0, max_health)
    if current_health <= 0:
        emit_signal("health_depleted")

func increase_health(amount: int) -> void:
    current_health += amount
    current_health = clamp(current_health, 0, max_health)

func get_health() -> int:
    return current_health

signal health_depleted

While this approach separates behaviour, it requires the parent to be aware of the component, which almost negates the benefits of abstraction. In such cases, you might wonder whether it’s simpler to just include this logic directly in the Player class and avoid the additional complexity. However, doing so would bring us back to the original problem of tightly coupled code. But what if we approached this differently?

Inverting the Relationship

Instead of making the parent aware of the components, I propose that components should be self-contained and should expose ways to interact with them using signals. This way, the parent doesn’t need to be aware of the capabilities provided by the components.

extends Node

# Target node, which could be the parent, owner, root, current scene, etc. In this case, we'll use the parent for the health component.

@export var target: Node :
    get:
        if target == null:
            target = get_parent()
        return target
        
# Properties to simplify reading and writing metadata to the target.

var max_health: int :
    get:
        return target.get_meta("max_health", 1)
    set(v):
        target.set_meta("max_health", v)        

var health: int :
    get:
        return target.get_meta("health", max_health)
    set(v):
        target.set_meta("health", clamp(v, 0, max_health))     

# Behaviour methods for the node. There can be multiple methods.

func modify_health(amount: int):
    health += amount
    if health == 0:
        target.emit_signal("health_depleted")

# Register custom signals to the target, binding them to our component's methods.

func _enter_tree() -> void:
    target.add_user_signal("modify_health", [{
        name = "amount",
        type = TYPE_INT
    }])
    target.add_user_signal("health_depleted")
    
    target.connect("modify_health", modify_health)
    
func _exit_tree() -> void:
    target.remove_user_signal("modify_health")
    target.remove_user_signal("health_depleted")

At first glance, this might seem more complex, but it fully leverages signals, eliminates sources of coupling, and avoids spaghetti code.

The Target

Notice that we’re not using @onready or $NodePath. Instead, we decouple the component from the scene tree structure, making it less sensitive to changes. This approach allows us to specify the target directly in the editor. If the scene tree structure changes, the target generally updates without requiring manual intervention. It also enables us to jump directly to the connected node from the inspector. The target acts as a mediator, allowing components and other game elements to interact without hard references.

Note: While this approach usually works well, bugs and quirks in Godot may occasionally cause these references to decouple. This is rare, but it can happen.

Entering and Exiting the Tree

This pattern’s core lies in using the _enter_tree and _exit_tree methods to register everything before the _ready phase. This ensures that any dependencies can be set up in the _ready phase. Additionally, the component cleans itself up when exiting the tree, meaning the behaviour only exists when the node is attached, avoiding memory leaks and other issues.

Registering the Signals

We use add_user_signal to bind custom signals to the target. These signals allow us to use the behaviour via the parent and let external components listen for events. Generally, you want to bind signals to callables on the component itself, as we do with modify_health in our health component. Sometimes, however, you might expose signals like health_depleted for other components to react to.

Storing Component Data

It’s generally a good idea to store the component’s data on the target using metadata. This way, you don’t need to define a script on the target just for variable storage, and other components can read the data without reaching into the target node’s children. Getters and setters make this cleaner and simpler. Otherwise, you might end up with cumbersome patterns like:

target.set_meta("health", target.get_meta("health", target.get_meta("max_health", 1)) + amount)

This is wordy, hard to read, and becomes tedious when used multiple times in your script.

Is This More Work?

Initially, yes. However, since we have common patterns here, we can create an abstraction and make strategic use of inheritance to enhance our composition. Let’s make a few assumptions about our behaviour:

We likely want:

  • A way to create custom signals.
  • A way to bind callables to these signals.
  • A way to add the target to groups.

And we want the reverse when exiting the tree.

Let’s create a class that handles all of this, allowing us to focus on behaviour instead.

class_name Behaviour
extends Node

@export var target: Node :
    get:
        if target == null:
            target = get_parent()
        return target

func _enter_tree() -> void:
    if has_method("signals_to_create"):
        var sigs: Dictionary = call("signals_to_create")
        for key in sigs.keys():
            target.add_user_signal(key, sigs[key])

    if has_method("signals_to_bind"):
        var sigs: Dictionary = call("signals_to_bind")
        for k in sigs:
            target.connect(k, self[sigs[k]])
    
    if has_method("groups_to_add"):
        for group: StringName in call("groups_to_add"):
            target.add_to_group(group)
    
func _exit_tree() -> void:
    if has_method("groups_to_add"):
        for group: StringName in call("groups_to_add"):
            target.remove_from_group(group)
            
    if has_method("signals_to_bind"):
        var sigs: Dictionary = call("signals_to_bind")
        for k in sigs:
            target.disconnect(k, self[sigs[k]])
            
    if has_method("signals_to_create"):
        var sigs: Dictionary = call("signals_to_create")
        for key in sigs.keys():
            target.remove_user_signal(key)

This class handles the creation and binding of signals and adding the target to groups, allowing subclasses to optionally create methods that define the signals and bindings.

Creating new behaviours becomes trivial. Our health component would now look something like this:

extends Behaviour

# Accessing our metadata

var max_health: int :
    get:
        return target.get_meta("max_health", 1)
    set(v):
        target.set_meta("max_health", v)        

var health: int :
    get:
        return target.get_meta("health", max_health)
    set(v):
        target.set_meta("health", clamp(v, 0, max_health))   

# Optional methods to create and bind signals

func signals_to_create():
    return {
        modify_health = [
            {name = "amount", type = TYPE_INT}
        ],
        health_depleted = []
    }
    
func signals_to_bind():
    return {
        modify_health = "mod_health"
    }

# Behaviour implementation

func mod_health(amount):
    health += amount
    if health == 0:
        target.emit_signal("health_depleted")  

Now, it’s as simple as calling emit_signal("modify_health", -1) on the target from anywhere in your code to activate the behaviour.

Note To avoid errors, you should check to see if the signal exists when using this pattern, using node.has_signal("modify_health").

Final Thoughts

In this post, we’ve explored an alternative approach to applying the principle of composition over inheritance in Godot. By inverting the relationship between components and their parent nodes, we can achieve greater flexibility and decoupling in our code. Instead of relying on the parent to manage its components directly, we allow components to manage themselves, exposing their behaviour by leveraging Godot’s signal system.

This method not only reduces the risk of creating tightly coupled, brittle systems but also allows for more modular and reusable code. By decoupling the components from the scene tree structure and using metadata to store data, we create a more robust system that is less sensitive to changes and easier to maintain. The added abstraction layer might seem like more work initially, but the benefits become apparent as your project grows in complexity. With this approach, you gain a more scalable, maintainable, and adaptable codebase.

Ultimately, this pattern of composition fosters a cleaner, more organised architecture, which can significantly reduce the likelihood of running into the dreaded spaghetti code. While it may deviate from more traditional practices, the long-term advantages make it a compelling choice for Godot developers seeking to build flexible and maintainable systems.