Best practice to instantiate nodes with inherited classes

Godot Version

4.2.1

Question

I’m building a 2D hex based game using Godot Script. There will be multiple hex types, each with its own behaviour. I was able to make this work with some advice and experimenting, but I still don’t understand why.

Fair warning, this is going to be a long one :wink:

My original implementation was:

Create a single ‘hex_tile’ scene which will provide a sprite and a collision2D nodes
hex_node_org

A ‘HexTile’ class which implements shared game logic. I did not attach this class to the ‘hex_tile’ scene.

class_name HexTile extends Area2D

@onready var hex_sprite = $HexSprite
# Hex cube coordinates
@export var coordinate_x : int = 0
@export var coordinate_z : int = 0
@export var coordinate_y : int = 0

func _input_event(viewport: Node, event: InputEvent, shape_idx: int) -> void:
    if event.is_action_pressed("mouse_left_click"):
        print_my_coordinates()

func set_hex_position(_position: Vector2):
    position = _position

func set_hex_cube_coordinates(row_index: int, column_index: int):
    coordinate_x = row_index
    coordinate_z = column_index
    coordinate_y = - coordinate_x - coordinate_z
    
func set_texture(texture_path: String):
    hex_sprite.Texture = load("res://" + texture_path)

func print_my_coordinates():
    print("Cube coordinates: " + str(coordinate_x) 
    + ", " + str(coordinate_y) + "; " + str(coordinate_z))

A specific class for each type of hex. This class will inherit from the generic ‘HexTile’ class

class_name HexTileForest extends HexTile

    func print_my_coordinates():
        print("I am a forest tile")
        super.print_my_coordinates()

An empty level. I instantiate each tile and assign to it the appropriate script and texture depending on its type.

class_name Level extends Node2D
    
    func _ready():
        # Initialise hex tile via code
        var hex_tile_scene: PackedScene = load("res://hex_tile.tscn")
        var new_hex_tile = hex_tile_scene.instantiate()
        add_child(new_hex_tile)
        new_hex_tile.set_script(load("res://hex_tile_forest.gd"))
        new_hex_tile.set_texture("hex_tile_forest.png")
        new_hex_tile.set_hex_position(Vector2(100, 200))
        new_hex_tile.set_hex_cube_coordinates(0,1)

I’m getting this error in hex_tile.set_texture:
Invalid set index ‘Texture’ (on base: ‘Nil’) with value of type ‘CompressedTexture2D’.

This means that the tile instance is not set properly, the ‘hex_sprite’ call returns null.
There are some interesting things to see in the inspector in run time:

The script is assigned as expected:

‘hex_sprite’ is null as expected.

I figured this has to do with the order of initialization. I tried assigning the script to the hex instance before it’s added as a child, with the same result.

Next step
Following up on advice I moved the initialisation code from level._ready() to level._init(). I see the same result as before. It occurred to me that perhaps variable hex_sprite is only available for use in _ready() because of the keyword:

@onready var hex_sprite = $HexSprite

There’s no @oninit keyword, so I’ll leave this as is for now.

I injected print statements in _ready() and _init() functions for all three classes to try and understand the order in which they happen:

level is initialized
forest tile is initialized
hex tile is
initialized forest tile is ready
hex tile is ready
Level is ready

Finally, I broke off the code in Level between _ready() and _init():

class_name Level extends Node2D

func _ready():
    var new_hex_tile = get_child(0)
    new_hex_tile.set_texture("hex_tile_forest.png")
    new_hex_tile.set_hex_position(Vector2(100, 200))
    new_hex_tile.set_hex_coordinates(0,1)

func _init():
    var hex_tile_scene: PackedScene = load("res://hex_tile.tscn")
    var new_hex_tile = hex_tile_scene.instantiate()
    new_hex_tile.set_script(load("res://hex_tile_forest.gd"))
    add_child(new_hex_tile)

This works but left me confused. Obviously, _init() is executed ahead of _ready(). Why is the code working when split? In both cases, I was accessing hex_sprite from _ready(). Hopefully it’s not a race condition.

Finally
I updated class HexTileI removed the declaration:

class_name HexTile extends Area2D

# This is removed: @onready var hex_sprite = $HexSprite
var hex_sprite
# Hex cube coordinates
var coordinate_x : int = 0
var coordinate_z : int = 0
var coordinate_y : int = 0

.
.
.All functions remain the same
	
func _init():
	hex_sprite = $HexSprite

And updated level class, all initialisation is done in _init():

func _init():
	var hex_tile_scene: PackedScene = load("res://hex_tile.tscn")
	var new_hex_tile = hex_tile_scene.instantiate()
	new_hex_tile.set_script(load("res://hex_tile_forest.gd"))
	add_child(new_hex_tile)
	new_hex_tile.set_texture("hex_tile_forest.png")
	new_hex_tile.set_hex_position(Vector2(100, 200))
	new_hex_tile.set_hex_coordinates(0,1)

I’d really appreciate an explanation on why it happening under the hood or a reference to an article that covers this in details.

_init is called when an Object is constructed, for Scene, it will call the _init() function when you .instantiate() it
meanwhile
_ready is called when a node added as a child of other node, in other words, it’s not an orphan node anymore
so when you .instantiate a Node inherited type class node, it actually became an Orphan Node first, then when you add_child(the_node), it has a parent and owner.

so this is not about a race condition. _init() should be triggered on any node no matter what first when you try to instantiate() the scene/node, then the _ready() only triggered if the node entered the main scene tree as a child of some other node

about this error:

the hex_sprite since it’s @onready variable,

if this Area2D node :

never got actually added child first, the hex_sprite node will remain null

Thank you, that does bring some order into the mess. The one thing I’m still not sure about is why splitting the code worked.

this could be just, hex_sprite node is not yet added when you just add_child new_hex_tile, maybe you can try add something like/change it to:

func _ready():
        # Initialise hex tile via code
        var hex_tile_scene: PackedScene = load("res://hex_tile.tscn")
        var new_hex_tile = hex_tile_scene.instantiate()
        add_child(new_hex_tile)
        await get_tree().physics_frame
        new_hex_tile.set_script(load("res://hex_tile_forest.gd"))
        new_hex_tile.set_texture("hex_tile_forest.png")
        new_hex_tile.set_hex_position(Vector2(100, 200))
        new_hex_tile.set_hex_cube_coordinates(0,1)

see if it will work

1 Like

Interesting. This solution does imply that there is a race condition between set_script and the initialization of the hex_sprite node. It makes sense.

This is a matter of initialization order and a typo. No race conditions are present.

Order Matters

The following expands on what @zdrmlpzdrmlp was talking about.

Simple Flow

Keep this flow in mind:

  1. _init()
  2. add to scene tree
  3. _ready()
  4. idle

Review node’s scene tree callbacks.

Bad Flow

func _ready():
	var scene = load("some.tscn")
	var instance = scene.instantiate()
	add_child(instance)
	instance.set_script(load("some.gd"))

In the above snippet the flow looks like this:

  1. instance._init()
  2. add instance to scene tree
  3. instance._ready()
  4. instance.set_script(...)
  5. instance._init(), for new script.
  6. idle

Notice how the instance’s _ready() is not called with the new script.

Good Flow

Now, when you flip the following two lines:

instance.set_script(load("some.gd"))
add_child(instance)

The flow becomes:

  1. instance._init()
  2. instance.set_script(...)
  3. instance._init(), for new script.
  4. add instance to scene tree
  5. instance._ready()
  6. idle

Now, the new script has been initialized, added to the scene tree, and its subtree
is ready.

Conclusion

This is the desired flow for your new_hex_tile. Since
new_hex_tile.set_texture(...) accesses the onready $HexSprite child node,
set_texture(...) must be called after HexTile._ready().

The result should look like:

class_name Level extends Node2D

func _ready():
	# Initialise hex tile via code
	var hex_tile_scene: PackedScene = load("res://hex_tile.tscn")
	var new_hex_tile = hex_tile_scene.instantiate()
	new_hex_tile.set_script(load("res://hex_tile_forest.gd"))
	add_child(new_hex_tile) # add to scene tree after setting a new script
	new_hex_tile.set_texture("hex_tile_forest.png")
	new_hex_tile.set_hex_position(Vector2(100, 200))
	new_hex_tile.set_hex_cube_coordinates(0,1)

The tYpo

Sprite2D has the texture property.

func set_texture(texture_path: String):
	# `texture` not `Texture`
	hex_script.texture = load(...)

Extras

  • You can demo the flows by attaching scripts to the nodes in “hex_tile.tscn”
    that print on _init, _ready, _enter_tree.
  • Check out creating custom resources.
    • Something like a HexTileData resource.
    • Have multiple tile data resources: hex_tile_forest.tres, hex_tile_ice.tres, hex_tile_lava.tres
    • Pass the hex tile resource to the “hex_tile.tscn” instance.
    • HexTile sets itself up using the tile resource data.

Thanks a lot @indicainkwell this is so useful. I t works and I learned a lot. Extra thanks for linking to the resources article.

1 Like