【Godot】Dependency Management in Godot: Design Patterns to Avoid Cyclic References

Created: 2025-12-10Last updated: 2025-12-16

Design patterns to prevent memory leaks from cyclic references. Learn how to use WeakRef, signals, and Autoload.

Overview

When developing games with Godot Engine, managing dependencies between scripts and scenes is an unavoidable topic. In particular, if you design without understanding the reference counting mechanism, you'll face memory leaks from "cyclic references."

This article explains the cyclic reference problem and introduces design patterns utilizing WeakRef, signals, and Autoload.


Godot's Memory Management and Cyclic References

Reference Counting System

Godot objects are mainly classified into two types:

TypeExamplesMemory Management
Node derived classesCharacterBody2D, etc.Released when removed from scene tree (queue_free())
RefCounted derived classesResource, Array, DictionaryAuto-released when reference count reaches 0

Memory leaks from cyclic references primarily occur with RefCounted derived classes.

How Cyclic References Cause Memory Leaks

Object A  --->  Object B
    ^               |
    |_______________|

When A and B reference each other, even after external references are gone, both reference counts remain at 1 or higher. As a result, they're never freed from memory.


Common Mistakes and Best Practices

Common MistakeBest Practice
Parent references child, child also directly references parentChild should notify parent via get_parent() or signals
Item resource references Player, Player also references ItemResources hold data only, Player passes as argument when using
SFX, UI, etc. directly modify Autoload internalsSingleton provides modification methods, others just call them
Mutual references for temporary effectsUse WeakRef for one side as weak reference, or notify effect completion via signal

Solution 1: WeakRef (Weak Reference)

The most direct way to break cyclic references. Uses weak references that don't increment the reference count.

Practical Example: Equipment and Character

# Equipment.gd (RefCounted)
class_name Equipment
extends RefCounted

var owner_ref: WeakRef # Weak reference

func get_owner() -> Character:
    if owner_ref:
        var ref = owner_ref.get_ref()
        if ref:
            return ref
    return null
# Character.gd (Node)
class_name Character
extends CharacterBody3D

var equipped_item: Equipment # Strong reference

func equip(item: Equipment):
    if equipped_item:
        unequip()
    equipped_item = item
    equipped_item.owner_ref = weakref(self)

func unequip():
    if equipped_item:
        equipped_item.owner_ref = null
        equipped_item = null

When Character is freed, Equipment's owner_ref automatically becomes invalid (null).


Solution 2: Loose Coupling with Signals

Signals are the most Godot-like way to manage dependencies.

Practical Example: Player HP and UI

# Player.gd (Signal emitter)
extends CharacterBody3D

signal hp_changed(current_hp: int, max_hp: int)

var max_hp: int = 100
var hp: int = max_hp:
    set(value):
        hp = clamp(value, 0, max_hp)
        hp_changed.emit(hp, max_hp)

func take_damage(amount: int):
    self.hp -= amount
# HUD.gd (Signal receiver)
extends Control

@onready var hp_bar: ProgressBar = $HPBar

func _ready():
    var player = get_tree().get_first_node_in_group("players")
    if player:
        player.hp_changed.connect(_on_player_hp_changed)

func _on_player_hp_changed(current_hp: int, max_hp: int):
    hp_bar.max_value = max_hp
    hp_bar.value = current_hp

Player doesn't know HUD exists - this is the beauty of loose coupling. The dependency is always one-way: "HUD → Player".


Solution 3: Autoload (Singleton)

Functionality shared across the entire game should be made into a singleton with Autoload.

Event Bus Pattern

Using Autoload as a global event bus enables communication across scenes.

# EventBus.gd (Autoload)
extends Node

signal enemy_defeated(position: Vector3, score_value: int)
signal item_collected(item_id: String)
# Enemy.gd (Signal emitter)
func die():
    EventBus.enemy_defeated.emit(self.global_position, 100)
    queue_free()
# ScoreManager.gd (Signal receiver)
func _ready():
    EventBus.enemy_defeated.connect(_on_enemy_defeated)

func _on_enemy_defeated(position: Vector3, score_value: int):
    Global.score += score_value

Enemy doesn't know ScoreManager exists. It just posts to the "bulletin board."


Summary

PatternPrimary UseBenefitsCaveats
WeakRefWhen mutual references are neededDirectly breaks cyclesMust check for null
SignalsLoose coupling between objectsDependencies are one-wayHard to track when many connections
AutoloadGlobal state management, event busAccessible from anywhereAvoid making it a "do-everything" object

Next steps:

  1. Observer pattern: The classic design pattern behind signals
  2. Using ResourceLoader: Load resources only when needed to optimize memory