Hunting orphan nodes in Godot
Thu Sep 25 2025
My plans for the evening were very simple. The final DLC for our game had just come out, and, really, all I wanted to do was to watch some garbage on YouTube while reading the people's reactions to the DLC on Discord.
Later that evening I saw a post on Bluesky from Smitner. Smitner is building a pretty twisted sudoku game, Hazard Pay, using Godot and was challenging followers to measure the orphan node count in their projects.
In Godot orphan nodes are nodes that live outside the scene tree. The name "orphan" is a bit of a misnomer, since they can have parents and grandparents, but there's just no connection to the scene tree. To put it simply, they're basically a memory leak waiting to happen.
So I fired up the Godot project for The Rise of the Golden Idol, booted up the game, and played through the first scenario. The orphan node counter (you can find it in the "Monitor" tab of the debugger) sat at "36".
I restarted the game again. As soon as the logos popped up, the counter instantly went up to "2" and the number didn't change until I booted up a scenario. I figured I can fix these two as a little learning experience. Can't be that hard, right? Since the logo scene is very simple, the orphan nodes can only come from the autoloads. Some of them are chunky, but at least I'm starting with a narrowed set of suspects.
So I whip up a little GDScript and put that as the top autoload:
extends CanvasLayer
var nodes: Array[Object] = []
func _enter_tree() -> void:
var root := get_tree().root
root.child_entered_tree.connect(_register_node)
nodes.append(self)
func _register_node(new_node: Node) -> void:
nodes.append(new_node)
new_node.child_entered_tree.connect(_register_node)
func _ready() -> void:
await get_tree().create_timer(2).timeout
var orphans := 0
var freed := 0
for n in nodes:
if not is_instance_valid(n):
# obviously, the first iteration didn't have this condition,
# so this crashed on the first run ...
freed += 1
continue
if not n.is_inside_tree():
orphans += 1
continue
prints(orphans, nodes.size() - freed)
To explain it shortly, I collect all created nodes in an array and then check whether they are still connected to the scene tree after two seconds.
Much to my disappointment, it printed an orphan count of zero. All of the nodes added to the tree on initialization were still connected.
This means that the two orphan nodes were never added to the scene tree after creation, which also meant that I didn't get my easy win.
I learn that Godot 4.5 has added a get_orphan_node_ids()
function to the Node
class, which could be a great help. That would also mean that I'd have to upgrade Rise's engine from Godot 4.3. It's notoriously easy to upgrade Godot projects to newer engine versions, but, for better or worse, I decided against it.
I roll up my sleeves and slowly start commenting out all the code that somewhat relates to dynamically creating nodes. All the new()
and instantiate()
calls. In time I realize that I might be too deep in the hole, but I'm also too invested to give up.
Once in a while I rerun the project to check the orphan node counter. Eventually, I reach a point where there's no dynamic node creation code left in the autoloads. Everything's commented out. And, yet, two orphan nodes remain.
I slightly curse myself for getting in this jam in the first place but realize that I've still got options—add-ons. Now, The Rise of the Golden Idol uses just two third-party add-ons—gdUnit4 and Controller Icons, both great software!
As expected, disabling gdUnit4 didn't yield any results, as it's not used at runtime anyway. Removing Controller Icons was a bit more involved, as it's utilized in multiple places in the UI of the game.
I get to a point where there are no more references to the add-on in the game, run it, and, holy shit, the orphan node counter is at zero. Re-enabling it brings it back up to two. I had finally found my orphan nodes or, at least, the cause of them. Turns out, all I had to do was update the add-on to its latest version, and I wouldn't even have this problem in the first place.
Even though two orphan nodes took me multiple hours to find, I felt empowered and wanted to go for more. As I mentioned before, starting from the logos up until finishing the first scenario generated 36 orphan nodes. Two down, 34 to go.
Thankfully, the first scenario, "Constriction," is quite small, so maybe this won't take too long. I yanked out all the hotspots and puzzle screens, but that didn't bring the number down. Now, throwing out the bottom UI, that's when we got back to zero.
After a bit of narrowing down, it turns out that the quick travel panel was the culprit. The buttons in that panel are instantiated when the panel loads but are only added to the tree once the panel is toggled. Meaning that if that panel never gets opened up, those nodes are left hanging in the air and taking up memory, remaining there until the program is terminated.
This meant that I had cleared all thirty-six orphan nodes that Godot originally reported. I was pretty satisfied, considering I hadn't planned to do anything like this at all, even though there are definitely more cases lurking deeper in the project.
Later on, I found out that there's a Node.print_orphan_nodes()
function, but its output wouldn't have been helpful for catching the orphans generated by Controller Icons. See the print output below:
47378859634 - Stray Node: (Type: Node)
356515846044 - Stray Node: (Type: Node)
The function is a bit more helpful identifying orphan nodes with children e.g. packed scenes. When I deliberately orphaned a hotspot, this was the output:
375708981150 - Stray Node: Spot (Type: Control)
375776090061 - Stray Node: Spot/Indicators/Highlighter (Type: AnimatedSprite2D)
375742535663 - Stray Node: Spot/Indicators (Type: Control)
375809644545 - Stray Node: Spot/Indicators/FocusGrabber (Type: Control)
375792867333 - Stray Node: Spot/Indicators/FocusIndicator (Type: AnimatedSprite2D)
Even though it doesn't output the class_name
I have given it, I can still recognize it by its structure, so that can definitely help. Regardless, I'm looking forward to just resorting to get_orphan_node_ids()
as my first hunting tool in the future. Hopefully that'll reduce this process from hours to minutes!
Thanks for reading all of this! I'm proud of myself for not making a single Batman joke. Now I expect someone to tell me how much harder I made this for myself. Please do. Ping me on Bluesky or Mastodon about it! Also, please go and check out Hazard Pay!