The terms ‘scene’ and ‘node’ can be used interchangeably at times. Where ‘scene’ is referring specifically to the top level node (not arbitrary sub-scenes instanced within it) and it needs to be swapped out with another scene (i.e. switching from the main menu into a game, or changing levels, or however your game functions structurally), this is when the singleton / autoload pattern is useful.
If you want nodes to communicate with one another within a scene, using a singleton / autoload is a boondoggle. Your scene can store all these variables and provide all these functions by itself. It can manipulate its children, call their functions, access their variables, etc. This is completely sufficient when the children are created statically (i.e. you have a Player node you create in the editor).
Any node can call arbitrary functions on each other and access each other’s variables as well. Once you have a reference to a node, you can do pretty much whatever you like with it. The root of the problem is discoverability. How do nodes come to be aware of one another? There are a few approaches.
It is possible to walk the scene tree from any arbitrary point and search for a node which matches some criteria. Take a node, call get_children()
, iterate that list, call get_children()
and so on (implemented as a recursive function). Along the way, you can do type comparisons, check for whatever criteria you’re interested in, or stash them in an array for later use.
# Scene root
extends Node2D
var enemies :Array[Enemy] = []
func _ready() -> void:
# ...
recursive_search(self)
# ...
func recursive_search(root :Node) -> void:
for c in root.get_children():
recursive_search(c) # search children
if c is Enemy:
# Here, we can stick c into an array of Enemies for later use,
# or call some Enemy-specific functions
enemies.append(e)
func _process(_delta :float) -> void:
for e in enemies:
e.go_forth_and_commit_evil()
Another strategy would be to use signals. These could either be built-in signals, like those triggered by input or physics events, or they can be bespoke signals created to represent more abstract events which only have meaning in the context of your game. Signals can be emitted with parameters, (i.e. a reference to the node which caused the signal to be emitted), and then any nodes responding to the signal can directly access that node’s functions and properties.
Signals are part of the general design pattern in Godot to “Call down, signal up.” A parent node should be aware of its children, and can just call their functions and access their properties. This handles communication down the tree. Children, meanwhile, generally shouldn’t rely on any state within their parents. Instead, they emit signals which are optionally subscribed to by the parent. This allows communication up the tree.
In situations like this, I highly recommend building a minimal proof-of-concept in another project (or an isolated scene), for exactly the reason you state. It might turn out to be a dead end and a waste of time, but in that case you learned, you didn’t break anything, and you didn’t waste an enormous amount of time trying to implement it at scale. My current project has more test scenes than actual gameplay.
Games are complex. They have a lot of moving parts. It is inevitable that things aren’t going to work out perfectly the first time around. It is inevitable that we will have to revise and refactor certain systems. We can try to anticipate it and limit it, but it is basically part of the process.