Like many other game engines that include editors, Godot allows you to expose variables declared in a script, that can then be modified through the editor. While GDScript is dynamically typed, when you export a variable to be modifiable through the editor, you must define a type the editor recognizes.

export(float) var floatValue = 0.5	# Default value used in the editor
export(NodePath) var pathToANode	# No Default value

NodePath is the variable type to use if you want to link to another Node in the scene, without resorting to hard-coding a path e.g. onready var anotherNode = get_node("a/node/path"). But that example doesn't retrieve a NodePath, it retrieves the actual Node itself. It's a distinction that's important to make--NodePath only points to a Node, and it must be resolved to obtain the actual Node.

When a script has the need to reference multiple other Nodes in a scene, resolving these NodePaths can become cumbersome.

export(NodePath) var nodePathA
export(NodePath) var nodePathB

onready var resolvedNodePathA = get_node(nodePathA)
var resolvedNodePathB

func _ready():
	resolvedNodePathB = get_node(nodePathB)

Using Reflection

By taking advantage of reflection, we can reduce the code above down to:

# These will be resolved in-place by Utils.LinkNodePaths(forNode)
export(NodePath) var nodePathA	
export(NodePath) var nodePathB

onready var _ready = Utils.LinkNodePaths(self)

This magic is made possible by Object.get_property_list. As the method name suggests, when given a Script, it returns all the properties declared in that script and its parents.

A Singleton

First things first, we need to create a singleton to hold our reflection methods, as well as store our cached properties. Reflection isn't free, but we can amortize the cost and make it cheap by caching the properties we're interested in, for each different Script we feed our reflection methods.

Creating the singleton is easy, and covered in so many different guides on GDScript, that I'll only glance over the steps:

  1. Create a new script, name it and save it somewhere.
  2. Go into Scene->Project Settings->AutoLoad and add the newly created script.
  3. Enable the Singleton checkbox if not already, and check the name. For this article, I'm naming it Utils.

We should now have a singleton available to us in all our other scripts, which we can access through Utils, or the name you used, if different.

Our Property Cache

Before we can do anything else, in our singleton script, we need to add and handle our cache.

var _cachedNodePathVariables = {}

func CacheNodePathVariables(node):
	var links = []
	var propertyList = node.get_property_list()
	
	for property in propertyList:
		if property.type == TYPE_NODE_PATH and property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
			links.append(property.name)
	
	_cachedNodePathVariables[node.get_script()] = links
	return links

Breaking it down, _cachedNodePathVariables is where we'll be storing our cache. It will be a dictionary where every key::pair is a Script and an array of properties for that Script, that we wish to cache.

We only wish to cache NodePath variables, that are explicitly defined in the script.

We get a list of all the properties stored within a Script, using Object.get_property_list(). We're calling this on a Node, but it will retrieve them from the Script attached to that Node. Even though every Node has its own instance of its attached Script, the properties will not change between instances.

Each property retrieved carries a lot of information along with it, but for our purposes, we only care about the type and usage. These are only used to filter the properties, as in the end, we'll only cache the names of the properties we don't filter out.

Because we only care about NodePath properties, we filter property.type against TYPE_NODE_PATH. We also only care about properties explicitely defined in the script, so we filter property.usage against PROPERTY_USAGE_SCRIPT_VARIABLE. property.usage is a bit-flag, so we use bitwise and between it and PROPERTY_USAGE_SCRIPT_VARIABLE, and check that it's != 0. GDScript implicitly casts numbers to bool when used in a conditional expression, which is why there's no explicit comparison to 0 in the example.

Unfortunately the documentation is quite vague on the exact coverage of PROPERTY_USAGE_SCRIPT_VARIABLE, and it's poorly named to boot, but in practice, it does seem to only refer to properties actually defined in the script. That is, it will not refer to a property that is defined internally by Godot.

Once we've filtered the properties using this criteria, we're left with only the properties we care about. We then store the names of these properties in the cache.

Of note is the Object.get_script() method, which we use to obtain the attached Script we use as our key in the cache. This does not retrieve an instance of the Script, but the base Script itself, which means that every Node we pass with the same base Script, will use the same cached data.

Resolving the NodePaths

We now have a way to properly cache and retrieve NodePath properties, so we can focus on actually resolving them.

func LinkNodePaths(node):
	var nodeScript = node.get_script()
	var links
	
	if _cachedNodePathVariables.has(nodeScript):
		links = _cachedNodePathVariables[nodeScript]
	else:
		links = CacheNodePathVariables(node)
	
	for link in links:
		var propertyValue = node.get(link)
		
		if propertyValue: 
			node.set(link, node.get_node(propertyValue))
		else:
			node.set(link, null)
	
	return true

This method is as simple as it looks. We retrieve the Script attached to node, and use that as the key for our cache. If nothing has been cached for that key, we go ahead and cache and retrieve the properties. If it has already been cached before, we just retrieve the properties from the cache.

It's now only a matter of iterating through the retrieved properties. Object has two methods we're interested in, get and set. They allow us to retrieve and assign the value of a property, by name. As before with Object.get_property_list(), calling these methods on a Node will act upon its attached Script, but in this case, we're acting upon the instance of the Script attached to Node.

For each property, we retrieve the stored NodePath. We then resolve the value of that NodePath to get the actual Node it pointed to, and set the value of the property to our newly resolved Node. This is how from within the target Script, it appears that the value of the property has been resolved in-place.

Targeting Single Properties

Sometimes you only want to resolve a single NodePath, and don't need the added overhead of the cache and property reflection. In that case, we can write a much simpler helper method.

func LinkNodePath(node, propertyName):
	var sharedInfo = node.get(propertyName)
	
	if sharedInfo and typeof(sharedInfo) == TYPE_NODE_PATH:
		node.set(propertyName, node.get_node(sharedInfo))
		
	return true

This method is so simple, it almost doesn't need any explanation. Given node to act upon, and the name of the property to resolve as propertyName, we retrieve the value from the property. After a quick check that it isn't null and that it is a NodePath, we can resolve the Node it points to and set the property to that Node.

When Ready

Something important to remember about these methods, is that they can only be called as the assignment to an onready variable or property, or after the target Node is ready. This is because a Node cannot access other Nodes in a scene until it has been added to the scene.

If you'll notice, both methods always return true. Due to the nature of when they can be called, and because it's a common idiom to keep a ready variable in a script, this makes a handy shortcut.

export(NodePath) var nodePathA	
export(NodePath) var nodePathB

onready var _ready = Utils.LinkNodePaths(self)

Both of our NodePath properties will be resolved for us as soon as our Node is ready, and for free we'll gain a flag that the rest of the code can check to see if the Node is ready.

Wrap-Up

Overall, in comparison to other languages, GDScript has limited reflection capabilities, but with smart use of it, we can make development faster and easier. Our primary goal here, has been to help alleviate the need for extremely fragile hard-coded paths to other Nodes in the scene.