//rp.wtf

Property proxying in Godot

Mon Oct 09 2023

Probably, a typical scenario in Godot is where you have a scene that consists of multiple nodes of various kinds with the intention of reuse.

For our example, let us use a theoretical scene that has a Sprite2D and a Label as their children. We could use that to display a list of inventory items in our game.

A Godot 4 scene tree consinsting of a parent Node2D and two children - a Sprite2D and a Label.

Probably the two main things you would want to change are the label text and the texture of the sprite.

One way you could reuse this scene is with inherited scenes (Scene → New Inherited Scene), but then you would be creating an inherited scene for every inventory item, and, especially in a game with a lot of items, that would be a lot of time better spent elsewhere, and it would also complicate your scripts since for every inventory item you would have a separate preload() or load() call each scene and so on.

Another way could be to work with "Editable Children" of the InventoryItem instances from a main inventory scene, but I also don't think that this approach is very good since you expose the "internals" of your reusable scene to a scene that should have no business seeing what's under the hood of your InventoryItem.

What we want to do is provide a clean interface to the consumers of our InventoryItem scene, so parent scenes know what they can change without breaking stuff. We will achieve that with property proxying, which is a quite common pattern. What that means is that we will combine GDScript exports with setters, and as a result, our InventoryItem will have properties that, upon change, will also update the properties of our sprite and label. This way, we will have a highly reusable and potentially limitlessly customizable scene without having to create inherited scenes or edit children of instantiated scenes.

How?

Lets start with the groundwork - attach a script to the InventoryItem node, pick "Object: Empty" as the template, and press "Create".

Write the following:


@tool
extends Node2D

@export var text: String:
    set = set_text
@export var texture: Texture2D:
    set = set_texture


func set_text(value: String) -> void:
    pass


func set_texture(value: Texture2D) -> void:
    pass

We define two exported properties, meaning that we can not only change them from code but also assign them through the Godot inspector:

inventory_item.gd showing two exported properties - Text and Texture

We use text of type String, since we will set the property text of our Label, and we use texture of type Texture2D since we will set the texture property of our Sprite2D. It isn't important that the property names match; I think it's just simpler to read and understand that way, but property names should be relevant to the purpose of the scene, so item_name instead of text and portrait instead of texture could work better.

We define two (for now, empty) setter functions for them. Setters allow us to run logic when a property is set.

Finally, a @tool keyword at the top tells the script to run in the editor, so we can instantly see the results of our properties being set.

Now, lets implement the setters themselves, which is the crucial part.


@onready var sprite: Sprite2D = $Sprite2D
@onready var label: Label = $Label


func set_text(value: String) -> void:
    text = value

    if not label:
        return

    label.text = value


func set_texture(value: Texture2D) -> void:
    texture = value

    if not sprite:
        return

    sprite.texture = value

First, we store references of our child nodes. The $ syntax is a nice syntax-sugarry way to fetch nodes by path, but if you plan on moving them around, I recommend using unique nodes so your script doesn't break if you decide to move the nodes into a container.

Both setters themselves are pretty much doing the same thing: they set the value of the property itself, stop executing if the reference to a child node does not exist yet, but if it does, set the property of the child. Already now, if you play around with the exported values, you can see the changes right away, as if you were editing the children directly:

So, you add child instances of InventoryItem to your main scene, set their properties, run the scene, and get a blank screen. WTF?

There's one final thing we're missing: if we set these properties on instantiation or the inspector instead of later on during the runtime, the setters get executed before the child components exist, so we unfortunately have to call them explicitly in the _ready() function, which is some extra bookkeeping we have to do to reap the benefits of this pattern. Together with that, our final script looks like this:


# inventory_item.gd

@tool
extends Node2D

@export var text: String:
    set = set_text
@export var texture: Texture2D:
    set = set_texture

@onready var sprite: Sprite2D = $Sprite2D
@onready var label: Label = $Label


func _ready() -> void:
    # call setters explictly so children get the initial values
    set_text(text)
    set_texture(texture)


func set_text(value: String) -> void:
    text = value

    if not label:
        return

    label.text = value


func set_texture(value: Texture2D) -> void:
    texture = value

    if not sprite:
        return

    sprite.texture = value

Script example

As you probably know, we don't have to use the inspector at all, and we can set the proxied properties programmatically:


const InventoryItem = preload("res://inventory_item.tscn")


func _ready() -> void:
# assuming we have a theoretical inventory array
    for item in inventory:
        var item := InventoryScene.instantiate()
        item.text = item.name
        item.texture = load(item.icon_path)

        add_child(item)

And that's it. Of course, the InventoryItem scene is a quite basic example, but it should serve as a nice and extendable basis to create lots of flexible and reusable scenes for your games.

If you see any typos, syntax errors, or other nonsense, don't hesitate to ping me! That said, thanks to @cpt_manlypink for pointing me to a couple mistakes!