Game Development with Godot & GDScript Module 4 – The scene tree, nodes and signals
Finding nodes: paths, groups, unique names
Reach Godot nodes with get_node, $ and @onready, scene-unique %names and groups, and wire scenes with exported references instead of paths that break.
What you will learn
- Find nodes with get_node, $ and @onready, and handle a missing node with get_node_or_null
- Use scene-unique %Names, and set the owner of nodes made in code so that they work
- Group nodes and reach them with get_nodes_in_group and call_group
- Connect scenes with exported node references and signals instead of fixed paths
Before you start
What you’ll learn · Before you start
What you will learn
- Find nodes with get_node, $ and @onready, and handle a missing node with get_node_or_null
- Use scene-unique %Names, and set the owner of nodes made in code so that they work
- Group nodes and reach them with get_nodes_in_group and call_group
- Connect scenes with exported node references and signals instead of fixed paths
Before you start
On this page
Rename two nodes of a level, and every script that finds other nodes by their place in the tree can break. The checker
at the end of this page renames World and Panel; a HUD that found the player with
get_node("/root/Main/World/Player") failed all 4 of its checks, and the same HUD passed all 4 once it found the player
through an exported reference and its label through a unique name.
This page goes through each way one node finds another: paths with get_node() and $, variables set with @onready,
scene-unique names, groups, and references that a scene sets up. For each one you will see, on a recorded run, what it
depends on and how it fails.
A node path is read like a folder path, from the node that asks
level.tscn has the same shape as the level of the previous lesson: Level, with Player and HUD under it, a Sword under
Player and a Score label under HUD. paths.gd, on Level, reaches the other nodes in each way a script can write a
path:
extends Node
# Attached to Level: reaches the other nodes of level.tscn by path, the ways a script can write one.
func _ready() -> void:
print("$Player/Sword -> ", $Player/Sword.name)
print("get_node(\"HUD/Score\") -> ", get_node("HUD/Score").name)
var score := $HUD/Score
print("Score: ../../Player -> ", score.get_node("../../Player").name)
print("/root/Level/HUD -> ", get_node("/root/Level/HUD").name)
print("Score's own path -> ", score.get_path())
print("from Score to Sword -> ", score.get_path_to($Player/Sword))
print("get_node_or_null(\"HUD/Lives\") -> ", get_node_or_null("HUD/Lives"))
print("get_node(\"HUD/Lives\") -> ", get_node("HUD/Lives"))
print("The function goes on after the error")
get_tree().quit() from session import files, run
files("project.godot", "level.tscn", "paths.gd")
# The last get_node() asks for a node that does not exist, so the run prints an engine error on purpose.
run("""
!godot --headless --no-header level.tscn
""") Output
$ godot --headless --no-header level.tscn
$Player/Sword -> Sword
get_node("HUD/Score") -> Score
Score: ../../Player -> Player
/root/Level/HUD -> HUD
Score's own path -> /root/Level/HUD/Score
from Score to Sword -> ../../Player/Sword
get_node_or_null("HUD/Lives") -> <Object#null>
ERROR: Node not found: "HUD/Lives" (relative to "/root/Level").
at: get_node (scene/main/node.cpp:1976)
GDScript backtrace (most recent call first):
[0] _ready (res://paths.gd:15)
get_node("HUD/Lives") -> <Object#null>
The function goes on after the error
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 paths_session.py
$Player/Sword is short for get_node("Player/Sword"): a path that starts at the node running the script and goes down
through its children, like a folder path. .. climbs to the parent, so from Score ../../Player goes up to HUD, then
to Level, then down to Player. A path that starts with / is absolute: it is read from root, the Window that Godot puts
at the top of every running game. Step through the paths of the run:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Every node has a path. While the game runs, level.tscn hangs under root, a Window that Godot makes. Read from the top like a folder path, Score's own path is /root/Level/HUD/Score, as get_path() printed.
- $Player/Sword from Level. A path without a leading / starts at the node that asks, here Level. Player is a child of Level and Sword a child of Player, so the path leads to Sword.
- ../../Player from Score. .. means the parent. From Score, the first .. is HUD and the second is Level; from there Player is a child. The path leaves the HUD's branch and enters the player's.
- /root/Level/HUD from anywhere. A path that starts with / is absolute. It is read from root whichever node asks, so it breaks as soon as any node on the way is renamed or moved.
- HUD/Lives leads nowhere. There is no Lives under HUD. get_node() printed Node not found and returned null, and the function went on; get_node_or_null() returned null without the error.
- The path Godot works out. get_path_to() gives the relative path from one node to another. From Score to Sword it is ../../Player/Sword, up two levels and down two.
The last two lines of the run are the case to handle. HUD has no child named Lives, so get_node() printed
Node not found with the line that asked, returned null, and the function went on. get_node_or_null() returned
null without the error, which is what you want for a node that may be missing on purpose. Either way, using the null is
the error that stops the function.
| Written as | Reaches |
|---|---|
$Player/Sword, get_node("Player/Sword") |
a node below this one, through each child on the way |
get_node("../Player") |
a node beside this one: up to the parent, then down |
get_node("/root/Level/HUD") |
a node named from the top, whichever node asks |
get_node_or_null("HUD/Lives") |
the same as get_node(), but null without an error when nothing is there |
The Godot documentation recommends avoiding .., because it makes a node depend on what is around it. Absolute paths
depend on even more: every node from root down.
Checkpoint
Check: Node paths, $ and get_node()
@onready waits until the children exist
A HUD reads its labels often, so it keeps them in variables. The question is when those variables get their values.
The same HUD, with a Score and a Lives label, runs here with three scripts. The first uses
@onready: An annotation on a member variable that delays its default value until just before the node's _ready(), when the node's children exist, as in @onready var label: Label = $Score.; the second sets score_label = $Score as a plain default value; the third uses
get_node_or_null("Lives") as one:
extends Node
# The HUD keeps its two labels in variables. @onready sets them just before _ready(), once the children exist.
@onready var score_label: Label = $Score
@onready var lives_label: Label = $Lives
func _init() -> void:
print(" _init: score_label ", score_label)
func _enter_tree() -> void:
print(" _enter_tree: score_label ", score_label)
func _ready() -> void:
print(" _ready: score_label ", score_label.name, ", lives_label ", lives_label.name) extends Node
# The same labels without @onready: the default value is worked out while the HUD is made, before its children exist.
var score_label: Label = $Score
func _ready() -> void:
print(" _ready: score_label ", score_label) extends Node
# get_node_or_null() in a default value: Godot accepts it, and it runs while the HUD is made.
var lives_label: Label = get_node_or_null("Lives")
func _ready() -> void:
print(" _ready: lives_label ", lives_label, ", though the HUD has a child named ", get_child(1).name) from session import files, run
files("project.godot", "show_hud.tscn", "show_hud.gd", "hud_onready.tscn", "hud_onready.gd", "hud_dollar.tscn",
"hud_dollar.gd", "hud_or_null.tscn", "hud_or_null.gd")
# The same HUD with three scripts; the second one does not even load, so its run prints a parse error on purpose.
run("""
godot --headless --no-header show_hud.tscn -- hud_onready.tscn
!godot --headless --no-header show_hud.tscn -- hud_dollar.tscn
godot --headless --no-header show_hud.tscn -- hud_or_null.tscn
""") Output
$ godot --headless --no-header show_hud.tscn -- hud_onready.tscn
instantiate() res://hud_onready.tscn
_init: score_label <null>
add_child()
_enter_tree: score_label <null>
_ready: score_label Score, lives_label Lives
$ godot --headless --no-header show_hud.tscn -- hud_dollar.tscn
instantiate() res://hud_dollar.tscn
SCRIPT ERROR: Parse Error: The default value uses "$" which won't return nodes in the scene tree before "_ready()" is called. Use the "@onready" annotation to solve this. (Warning treated as error.)
at: GDScript::reload (res://hud_dollar.gd:4)
GDScript backtrace (most recent call first):
[0] _ready (res://show_hud.gd:9)
ERROR: Failed to load script "res://hud_dollar.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
GDScript backtrace (most recent call first):
[0] _ready (res://show_hud.gd:9)
add_child()
$ godot --headless --no-header show_hud.tscn -- hud_or_null.tscn
instantiate() res://hud_or_null.tscn
add_child()
_ready: lives_label <Object#null>, though the HUD has a child named Lives
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 onready_session.py
A plain default value is worked out while the node is made, before the scene has added its children, so a lookup there
finds nothing. @onready delays the line until just before _ready(), when the children are in place. Follow the
three runs through the HUD’s first moments:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The HUD is made. instantiate() makes the HUD node and its script. Every member's default value is worked out now, as the node is made, before the scene has added a single child. score_label is still null.
- Then its children. The scene adds Score and Lives under the HUD. The HUD is not in the tree yet, and its @onready variables are still empty.
- In the tree, not ready yet. add_child(hud) puts it in the tree, and _enter_tree() still printed score_label <null>. @onready values are not set on entry.
- The @onready lines run. Just before _ready(), Godot runs every @onready line in a hidden function, @implicit_ready in backtraces. $Score now finds the label.
- _ready() can use them. _ready() printed score_label Score and lives_label Lives. Code that runs in _ready() or later can use both.
- $ without @onready. hud_dollar.gd sets score_label = $Score as a plain default. It would run when the HUD is made, before Score exists, so Godot refuses the script with a parse error that suggests @onready.
- get_node_or_null() slips through. hud_or_null.gd uses get_node_or_null("Lives") as a default. The parser accepts it, the line runs before Lives exists, and lives_label stays null for good, with no error.
With @onready, score_label was still null in _init() and in _enter_tree(), and held the Score label in
_ready(). The $Score default never ran at all: Godot refused the script with a parse error that names the cure,
Use the "@onready" annotation. That message is a warning that Godot 4.7 treats as an error by default, so this
mistake is caught before the game starts. The third script is the trap.
get_node_or_null() as a plain default
var lives_label: Label = get_node_or_null("Lives") passes the parser, runs while the HUD is made, and leaves
lives_label null for good: the run printed <Object#null> though the HUD had a child named Lives. Write
@onready var lives_label: Label = $Lives instead, so the line runs once Lives exists.
Checkpoint
Check: When @onready variables are set
A unique name survives moves, inside its own scene
A path from a HUD to its label names every node in between, so moving the label into a new panel breaks it. A
scene-unique node: A node marked Access as Unique Name, which any node of the same scene reaches as %Name, whatever lies between. It is found through the node's owner, so other scenes reach it only through the instance, as in Player/%Eyes. avoids that. In the editor, right-click the node in the Scene dock
and choose Access as Unique Name; a % sign appears beside it, and the scene file stores
unique_name_in_owner = true. Code then reaches it as %Score or get_node("%Score"), whatever lies between.
main.tscn marks its Score label unique. It also instances player.tscn as Player, and in that scene a node named Eyes
is unique too:
extends Node
# Attached to Main. Score is marked unique in main.tscn, Eyes in player.tscn, which Main instances as Player.
func _ready() -> void:
print("%Score -> ", found("%Score"))
print("$HUD/Panel/Score -> ", found("HUD/Panel/Score"))
print("-- rename Panel to TopBar")
$HUD/Panel.name = "TopBar"
print("%Score -> ", found("%Score"))
print("$HUD/Panel/Score -> ", found("HUD/Panel/Score"))
print("%Eyes -> ", found("%Eyes"))
print("Player/%Eyes -> ", found("Player/%Eyes"))
print("owner of Score: ", %Score.owner.name, ", owner of Eyes: ", $Player/Body/Eyes.owner.name)
print("-- a Lives label made in code, marked unique")
var lives := Label.new()
lives.name = "Lives"
$HUD/TopBar.add_child(lives)
lives.unique_name_in_owner = true
print("%Lives, no owner yet -> ", found("%Lives"))
lives.owner = self
print("%Lives, owner = Main -> ", found("%Lives"))
get_tree().quit()
func found(path: NodePath) -> String:
var node := get_node_or_null(path)
return node.name if node else "not found" from session import files, run
files("project.godot", "main.tscn", "unique_names.gd", "player.tscn")
run("""
godot --headless --no-header main.tscn
""") Output
$ godot --headless --no-header main.tscn %Score -> Score $HUD/Panel/Score -> Score -- rename Panel to TopBar %Score -> Score $HUD/Panel/Score -> not found %Eyes -> not found Player/%Eyes -> Eyes owner of Score: Main, owner of Eyes: Player -- a Lives label made in code, marked unique %Lives, no owner yet -> not found %Lives, owner = Main -> Lives
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unique_session.py
Step through the run, with main.tscn on the left and the instanced player.tscn on the right:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- %Score from Main. Score is marked Access as Unique Name in main.tscn, and Main owns it. get_node("%Score") from Main finds it without naming HUD or Panel.
- Panel is renamed. The run renames Panel to TopBar. %Score still finds the label, while the path HUD/Panel/Score now leads nowhere.
- %Eyes from Main. Eyes is unique too, but in player.tscn, whose root Player owns it. From Main, %Eyes found nothing, because a unique name belongs to the scene that owns the node.
- Through the instance. Player/%Eyes found it. The path first reaches Player, the instance, and then looks up Eyes among the unique names that Player owns.
- A node made in code. A Lives label added by code and marked unique was not found at first, because a node made in code has no owner. After lives.owner = self, %Lives found it.
What decides all of this is the node’s owner: The node that a node belongs to for saving and for unique names: for every node a scene file makes, the root of that scene. A node made in code has no owner until its owner property is set.. Each node a scene file creates is owned by that
scene’s root, and a unique name is registered with the owner. Score’s owner is Main, so %Score from Main found it,
and still found it after Panel became TopBar. Eyes’ owner is Player, the root of player.tscn, so %Eyes from Main
found nothing, while Player/%Eyes went to the instance first and found it there. The Scene unique nodes page states
the rule plainly: only nodes of the same scene can look up its unique nodes by name.
Nodes made in code have no owner. The Lives label that the run added and marked unique was not found until the line
lives.owner = self made Main its owner. The same owner rule decides which nodes are saved when a scene is packed into
a file, which a later lesson builds on.
Checkpoint
Check: Scene-unique names and owners
Groups find every node of a kind, wherever it is
When an alarm goes off, every guard must react, and the guards sit in different parts of the level, some placed in the
editor and some spawned during the game. A group: A name that tags nodes, such as guards. A node can be in many groups, and the SceneTree lists or calls the members that are inside the tree, in scene hierarchy order. tags nodes with a name, wherever they are. In
the editor, the Groups dock adds the selected node to one; in a scene file it is groups=["guards"] on the node’s line;
in code it is add_to_group("guards"). castle.tscn has two guards in the group, one under Tower and one under Gate,
and alarm.gd adds a third while the game runs:
extends Node
# Attached to Level. Guard1 and Guard2 are in the "guards" group in castle.tscn; Guard3 is made while the game runs.
const Guard := preload("res://guard.gd")
func _ready() -> void:
var guard3: Node = Guard.new()
guard3.name = "Guard3"
guard3.add_to_group("guards")
print("Guard3 is in the group: ", guard3.is_in_group("guards"))
print("guards before add_child: ", names(get_tree().get_nodes_in_group("guards")))
$Gate.add_child(guard3)
print("guards after add_child: ", names(get_tree().get_nodes_in_group("guards")))
print("first guard: ", get_tree().get_first_node_in_group("guards").name,
", guards: ", get_tree().get_node_count_in_group("guards"))
print("call_group(\"guards\", \"alert\", \"the gate\"):")
get_tree().call_group("guards", "alert", "the gate")
print("call_group(\"guards\", \"alret\", \"the gate\"), with a typo:")
get_tree().call_group("guards", "alret", "the gate")
print("(no guard moved, and no error)")
get_tree().quit()
func names(nodes: Array[Node]) -> Array[String]:
var out: Array[String] = []
for node in nodes:
out.append(node.name)
return out extends Node
# A guard of the "guards" group.
func alert(where: String) -> void:
print(" ", name, " runs to ", where) from session import files, run
files("project.godot", "castle.tscn", "alarm.gd", "guard.gd")
run("""
godot --headless --no-header castle.tscn
""") Output
$ godot --headless --no-header castle.tscn
Guard3 is in the group: true
guards before add_child: ["Guard1", "Guard2"]
guards after add_child: ["Guard1", "Guard2", "Guard3"]
first guard: Guard1, guards: 3
call_group("guards", "alert", "the gate"):
Guard1 runs to the gate
Guard2 runs to the gate
Guard3 runs to the gate
call_group("guards", "alret", "the gate"), with a typo:
(no guard moved, and no error)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 groups_session.py
Guard3 was in the group as soon as add_to_group() ran, but the tree’s list held only Guard1 and Guard2 until
add_child() put Guard3 in the tree: the SceneTree’s group methods see only nodes inside the tree. After that, all
three answered, in scene hierarchy order.
| Call | What it gives |
|---|---|
get_tree().get_nodes_in_group("guards") |
every member in the tree, in hierarchy order |
get_tree().get_first_node_in_group("guards") |
the first member, or null when the group is empty |
get_tree().call_group("guards", "alert", "the gate") |
calls alert("the gate") on every member |
is_in_group("guards") |
whether this node is in the group, in the tree or not |
A typo in call_group() is silent
call_group("guards", "alret", "the gate") moved no guard and printed no error, because call_group() skips
members that lack the method. Keep the method name in one place, such as a constant both scripts use, or check the
effect in a test.
Checkpoint
Check: Groups and call_group()
Exported references and signals keep scenes apart
Back to the HUD of the opening. It must show the player’s health, and the player lives in another branch of the main scene. Writing the player’s path into the HUD’s script ties the HUD to that one layout. The HUD of the practice below declares the player instead:
@export var player: Node2D
The slot then appears in the HUD’s Inspector, and in main.tscn, which holds both nodes, you pick the player for it.
The scene file stores the choice as a NodePath: A built-in type for a path to a node, written ^"Player/Sprite", already split into its parts. After a colon it can name a property, as in ^"Player/Sprite:modulate"; a leading / makes it absolute. More in “Strings, StringName and text formatting” from the HUD,
player = NodePath("../World/Player"), and instantiate() turns it into the node itself before the scene enters the
tree. The checker renamed World after that point, and the HUD still had its player.
The other direction matters as much. When the player has news, such as lost health, it should not look for a HUD at all: it emits a signal: A named event that an object emits, such as a button's pressed or a custom health_changed. Every function connected to it is called each time it is emitted, so the sender needs no reference to its listeners. More in “Signals and connections”, and the scene that holds both connects it to the HUD. This rule of thumb is often put as call down, signal up: A rule of thumb for Godot scenes: a parent calls methods on its children, and a child reports events by emitting signals, so no child depends on the scene that uses it.: a parent calls methods on its children, and a child reports events with signals, so no child depends on where it is used. The Scene organization page says the same in other words: design scenes to have no dependencies, and let an ancestor connect its children.
Choose a way for your own case. Answer the questions below the table, and it rules out the ways that would break:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Way | A node in between renamed | Across two scenes | When it fails | What it ties you to |
|---|---|---|---|---|
| Absolute path Written as: get_node("/root/Main/World/Player") | Poor breaks: the checker's rename of World gave Node not found | Mixed reaches anything, from anywhere, if the layout never changes | Poor at run time, with an error and a null | Poor the whole tree from root down |
| Relative path Written as: $"../World/Player" | Poor breaks: HUD/Panel/Score was not found after the rename | Mixed works, but .. reaches out of the scene | Poor at run time, with an error and a null | Mixed every node between the two |
| Unique name Written as: %HealthLabel | Good %Score still found the label after Panel became TopBar | Poor only inside its own scene; %Eyes was not found from Main | Mixed at run time, if the mark or the owner is missing | Good one name in the same scene |
| Group Written as: get_tree().get_first_node_in_group("player") | Good the tree is searched, whatever the names | Good finds members anywhere in the tree | Mixed quietly: an empty list, or a call that does nothing | Mixed a group name, a string the code must spell right |
| Exported reference Written as: @export var player: Node2D | Good set before the rename, so the checker's HUD kept its player | Good set where both are in view, in the parent scene | Mixed visible: an empty slot in the Inspector | Good only the type it expects |
| The other node signals Written as: the player emits health_changed | Good the connection is to the node, not a path | Good a parent connects it; neither scene knows the other | Mixed quietly, if nobody connected | Good the signal's name and arguments |
How the answers decide
- “Are the two nodes in the same scene file?” No, they come from different scenes: rules out Unique name. A unique name is found only inside the scene that owns the node: %Eyes from Main found nothing.
- “Are the two nodes in the same scene file?” No, they come from different scenes: adds a caveat to Relative path. A path that climbs out of its scene with .. depends on how the other scene is put together.
- “Can the nodes between them be renamed or moved later?” Yes, the layout will change: rules out Absolute path and Relative path. Both name every node on the way, so a rename or a move breaks them, as the checker's renames did.
- “Do you need one particular node, or every node of a kind?” Every node of a kind, such as all guards: rules out Unique name and Exported reference and adds a caveat to Absolute path and Relative path. For every node of a kind, a group finds them all, including nodes added later; one reference or path finds one.
- “Do you need one particular node, or every node of a kind?” One particular node: adds a caveat to Group. A group for one node works, but the code depends on a string, and an empty group fails quietly.
In short: inside one scene, a unique name or @onready with a short path; between scenes, an exported reference set
by the scene that holds both, or a signal; for every node of a kind, a group. Keep absolute paths out of game code.
Key idea
A node should depend on as little as possible around it: reach your own children by path or unique name, let the scene that holds two nodes connect them, and let children report events with signals.
Checkpoint
Check: Exported references and signals between scenes
Practise: a HUD that survives a new layout
hud.gd shows the player’s health, and it works, as long as nobody changes the main scene. It finds the player with an
absolute path and its label with $Panel/HealthLabel. Make it independent of the layout:
- Save
player.gd,hud.gdandcheck_hud.gdbelow in a folder with an emptyproject.godot, then buildmain.tscnin the editor: Main (Node), with World (Node2D) and its child Player (Node2D,player.gd), and HUD (Node,hud.gd) with Panel (Control) and its child HealthLabel (Label). - Change
hud.gdso that the player is an exportedNode2Dand the label is%HealthLabel; in main.tscn, mark HealthLabel with Access as Unique Name and pick Player for the HUD’s new Player slot in the Inspector. - Run
godot --headless --no-header --script check_hud.gdin a terminal in that folder.
extends Node2D
# The player's health, which the HUD shows.
var health := 100 extends Node
# The HUD shows the player's health. As written, it finds both nodes by where they are in the tree.
# Your task: reach the player through an exported node reference and the label through its unique name.
@onready var player: Node2D = get_node("/root/Main/World/Player")
@onready var health_label: Label = $Panel/HealthLabel
func _process(_delta: float) -> void:
health_label.text = "HP %d" % player.health extends SceneTree
# Checks hud.gd and main.tscn. Run it in the project's folder: godot --headless --no-header --script check_hud.gd
# It renames World to Level1 and Panel to TopBar before the scene enters the tree, as a later change of the
# layout would, then takes 25 health from the player and reads the label.
var main: Node
var frame := 0
var passed := 0
var total := 0
func _process(_delta: float) -> bool:
frame += 1
if frame == 1:
var source := FileAccess.get_file_as_string("res://hud.gd")
check(not source.contains("/root"), "hud.gd uses no absolute path")
main = load("res://main.tscn").instantiate()
main.get_node("World").name = "Level1"
main.get_node("HUD/Panel").name = "TopBar"
print("World is now Level1 and Panel is TopBar")
root.add_child(main)
elif frame == 2:
var hud := main.get_node("HUD")
check(is_instance_valid(hud.player) and hud.player.name == "Player", "the HUD has the player")
check(hud.health_label != null, "the HUD has its label")
main.get_node("Level1/Player").health = 75
elif frame == 3:
var text: String = main.get_node("HUD/TopBar/HealthLabel").text
check(text == "HP 75", "the label shows HP 75 (it shows %s)" % text)
main.free()
print("%d of %d checks passed" % [passed, total])
quit(0 if passed == total else 1)
return false
func check(ok: bool, what: String) -> void:
total += 1
passed += int(ok)
print("%s %s" % ["PASS" if ok else "FAIL", what]) The checker renames World to Level1 and Panel to TopBar before the scene enters the tree, takes 25 health from the player and reads the label. On the HUD as it starts, and on a finished one:
from session import files, run
# The checker on the HUD as it starts, which finds nodes by their place in the tree, then on an answer to the task.
files("practice/project.godot=start/project.godot", "practice/check_hud.gd=start/check_hud.gd",
"practice/player.gd=start/player.gd", "practice/hud.gd=start/hud.gd", "practice/main.tscn=start/main.tscn",
"practice/project.godot=right/project.godot", "practice/check_hud.gd=right/check_hud.gd",
"practice/player.gd=right/player.gd", "practice/solution/hud.gd=right/hud.gd",
"practice/solution/main.tscn=right/main.tscn")
run("""
cd start
!godot --headless --no-header --script check_hud.gd
cd ../right
godot --headless --no-header --script check_hud.gd
""") Output
$ cd start
$ godot --headless --no-header --script check_hud.gd
FAIL hud.gd uses no absolute path
World is now Level1 and Panel is TopBar
ERROR: Node not found: "/root/Main/World/Player" (absolute path attempted from "/root/Main/HUD").
at: get_node (scene/main/node.cpp:1973)
GDScript backtrace (most recent call first):
[0] @implicit_ready (res://hud.gd:5)
[1] _process (res://check_hud.gd:21)
ERROR: Node not found: "Panel/HealthLabel" (relative to "/root/Main/HUD").
at: get_node (scene/main/node.cpp:1976)
GDScript backtrace (most recent call first):
[0] @implicit_ready (res://hud.gd:6)
[1] _process (res://check_hud.gd:21)
SCRIPT ERROR: Invalid access to property or key 'health' on a base object of type 'null instance'.
at: _process (res://hud.gd:10)
GDScript backtrace (most recent call first):
[0] _process (res://hud.gd:10)
FAIL the HUD has the player
FAIL the HUD has its label
SCRIPT ERROR: Invalid access to property or key 'health' on a base object of type 'null instance'.
at: _process (res://hud.gd:10)
GDScript backtrace (most recent call first):
[0] _process (res://hud.gd:10)
FAIL the label shows HP 75 (it shows HP)
0 of 4 checks passed
[exit status 1]
$ cd ../right
$ godot --headless --no-header --script check_hud.gd
PASS hud.gd uses no absolute path
World is now Level1 and Panel is TopBar
PASS the HUD has the player
PASS the HUD has its label
PASS the label shows HP 75 (it shows HP 75)
4 of 4 checks passed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The starting HUD failed every check: both lookups printed Node not found, from @implicit_ready, the hidden step that
sets @onready variables, and each frame then stopped at player.health. The finished HUD showed HP 75. When your
own run reports 4 of 4 checks passed, go through the questions on the five ideas of the page; they start where your
answers so far are weakest, and each answer is explained as soon as you pick it:
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does $Score mean in GDScript, and why is it usually declared with @onready?
$Score is short for get_node("Score"): it finds the node named Score among the children of the node whose script
runs it, and $HUD/Score follows a longer path the same way. Scripts keep such nodes in member variables, and a member’s
default value is worked out while the node is made, before the scene has added its children, so a plain
var score_label = $Score would find nothing. Godot 4.7 refuses that line with a parse error that suggests @onready.
@onready var score_label: Label = $Score runs the lookup just before _ready(), when the children are in place. A
plain default with get_node_or_null() is not caught and stays null for good, so give every node lookup in a default
value the @onready annotation.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
After a teammate moves the Player under a new Characters node to tidy the level, the HUD stops updating and the output fills with Node not found errors from its @onready line, get_node("/root/Main/World/Player"). Fix it so that the next reorganisation cannot break the HUD, and explain your choice.
-
Fresher to mid
A wave spawner makes 10 guards with instantiate(), adds each to the guards group, calls get_tree().call_group("guards", "set_route", route), and only then adds them to the level. None of the new guards patrols, while the guards placed in the editor do. Explain and fix it.
Key takeaways
- A node path is read from the node that asks:
$Player/Swordgoes down,..goes up, and a leading/starts at root;get_node()reports a missing node and returns null,get_node_or_null()returns null quietly. - A plain default value runs before a node’s children exist;
@onreadyruns the lookup just before_ready(), and Godot 4.7 refuses$without it. - A scene-unique name,
%Score, skips the nodes in between and survives their renames, but only inside the scene that owns the node; a node made in code needs an owner first. - A group tags nodes anywhere in the tree; the SceneTree’s group calls see only nodes in the tree, in hierarchy order,
and
call_group()with a misspelt method does nothing at all. - Between scenes, use an exported reference that the scene holding both nodes sets, or a signal: call down, signal up.
Terms in this lesson
- @onready
- An annotation on a member variable that delays its default value until just before the node's _ready(), when the node's children exist, as in @onready var label: Label = $Score.
- call down, signal up
- A rule of thumb for Godot scenes: a parent calls methods on its children, and a child reports events by emitting signals, so no child depends on the scene that uses it.
- group
- A name that tags nodes, such as guards. A node can be in many groups, and the SceneTree lists or calls the members that are inside the tree, in scene hierarchy order.
- owner
- The node that a node belongs to for saving and for unique names: for every node a scene file makes, the root of that scene. A node made in code has no owner until its owner property is set.
- scene-unique node
- A node marked Access as Unique Name, which any node of the same scene reaches as %Name, whatever lies between. It is found through the node's owner, so other scenes reach it only through the instance, as in Player/%Eyes.
References
- Nodes and scene instances (Godot Engine)
- Scene unique nodes (Godot Engine)
- Groups (Godot Engine)
- Scene organization (Godot Engine)
- GDScript exported properties: nodes (Godot Engine)
- Node class reference: get_node, owner, unique_name_in_owner and groups (Godot Engine)
- SceneTree class reference: get_nodes_in_group and call_group (Godot Engine)
Report a problem with this lesson
Kept only in this browser. Your Learn progress