gamedev-skills/awesome-gamedev-agent-skillsPrüfung bestanden
SKILL DETAIL
godot-nodes-scenes
gamedev-skills/awesome-gamedev-agent-skills/godot-nodes-scenes
Structure a Godot 4.7 project with the scene tree and node composition: build reusable scenes, instance PackedScenes at runtime, navigate the tree safely, and register autoload singletons. Use when designing .tscn scenes, deciding how to split nodes, spawning instances with instantiate(), wiring autoloads, or fixing "node not found"/freed-node errors in a Godot project.
Installationen · 255Quelle ansehen
Installation
npx skills add https://github.com/gamedev-skills/awesome-gamedev-agent-skills --skill godot-nodes-scenes
Skill-Dateien
SKILL.md
Zuletzt synchronisiert · 24.10.2026
references/tree-and-instancing.md›
# Scene tree & instancing reference (Godot 4.7)
Depth companion to `godot-nodes-scenes`.
## Node paths
- `$Child`, `$Child/Grandchild` — relative path sugar for `get_node(...)`.
- `$"Node With Spaces"` — quote paths containing spaces or starting with a number.
- `%UniqueName` — a scene-unique node. Mark a node "Access as Unique Name" in the editor;
the reference then works no matter where the node sits in that scene's subtree.
- `get_node("../Sibling")` — `..` walks up to the parent.
- `get_tree().root` — the `Window` at the very top; the current scene is
`get_tree().current_scene`.
## Loading and instancing
```gdscript
const SCENE := preload("res://enemy.tscn") # constant path, loaded with the script
var dynamic := load(path_variable) # runtime path, may be a variable
var e := SCENE.instantiate() # PackedScene -> Node
add_child(e) # enters tree, _ready() fires
# To instance with the editor state of exported sub-resources, instantiate() is enough.
```
`instantiate()` flags: `PackedScene.GEN_EDIT_STATE_DISABLED` (default, runtime) vs
`GEN_EDIT_STATE_INSTANCE`/`GEN_EDIT_STATE_MAIN` (editor tooling only).
## Ownership when saving scenes from code
To build a scene in code and save it, every node you want serialized must have its
`owner` set to the scene root:
```gdscript
func save_built_scene() -> void:
var root := Node2D.new()
var child := Sprite2D.new()
root.add_child(child)
child.owner = root # without this, child is NOT saved
var packed := PackedScene.new()
packed.pack(root)
ResourceSaver.save(packed, "res://generated.tscn")
```
## Scene inheritance
In the editor, "New Inherited Scene" from a base `.tscn` creates a child scene that
inherits the base's nodes and can override properties or add nodes. Useful for variant
enemies sharing a common base. Changes to the base propagate to inherited scenes.
## Groups vs unique names vs autoloads
- **Unique name (`%`)** — reference a specific node *within the same scene*.
- **Group** — tag many nodes and act on all of them; see `godot-signals-groups`.
- **Autoload** — one global instance accessible by name across all scenes; use for
services and cross-scene state, not for per-level nodes.
## Freeing and validity
```gdscript
node.queue_free() # safe: deletes at end of current frame
if is_instance_valid(node): # guard against use-after-free
node.do_something()
node.free() # immediate; only when you control the timing
```
`get_child_count()`, `get_children()`, `move_child(node, idx)`, and
`reparent(new_parent)` (4.x) manage tree structure at runtime. `reparent` keeps global
transform by default.
## Deferred calls
When adding/removing nodes during physics callbacks or signal handlers, defer to avoid
"flushing queries" errors:
```gdscript
add_child.call_deferred(instance)
node.queue_free() # already deferred
```
scripts/check_godot_project.py›
#!/usr/bin/env python3
"""Check a Godot 4.x project for broken file references. Read-only.
Run it after creating, moving or renaming scenes, resources or scripts. It
reads the project's text files and reports references that Godot will fail
to load. It never edits anything and needs no Godot binary.
What it checks:
* ``[ext_resource]`` entries in ``.tscn``/``.tres`` files. Godot loads the
``uid`` first and falls back to ``path`` when the uid is unknown, so:
uid resolves -> OK (a stale or different ``path`` is only a warning);
uid unknown and ``path`` missing -> error; ``path`` exists but the uid is
unknown -> warning (Godot prints "invalid UID" and fixes it on save).
Uids are read from text headers, ``.import``/``.uid`` files and
uncompressed binary resources; compressed ``.scn``/``.res`` files hide
theirs, so a uid that might belong to one is only a warning.
* ``res://`` paths whose letter case differs from the file on disk. This
works in the editor on Windows and macOS but breaks on Linux and in
exported ``.pck`` files, which are case-sensitive.
* ``ExtResource("id")`` / ``SubResource("id")`` uses with no matching
declaration, sub-resources used before they are declared, malformed
``[ext_resource]`` headers and duplicate ids in one file.
* The same uid claimed by two files (``.tscn``/``.tres`` headers,
``.import`` files and ``.uid`` sidecar files).
* Scene trees: exactly one root node, every ``parent=`` path declared
earlier in the scene, and every ``[connection]`` ``from``/``to`` node.
Nodes inside instanced or inherited scenes are followed through their
``.tscn`` files; paths inside scenes that cannot be read (imported
``.glb``/``.blend`` models, binary ``.scn``) are skipped, not guessed.
A stale path that Godot 4.6+ can recover through ``parent_id_path`` /
``from_uid_path`` / ``to_uid_path`` is a warning.
* ``project.godot``: ``application/run/main_scene`` (``res://`` or
``uid://``), ``[autoload]`` entries and other ``res://`` setting values.
* ``.gd`` scripts: string-literal paths in ``preload()``, ``load()``,
``change_scene_to_file()`` and ``extends "..."`` (warnings).
* ``.uid`` and ``.import`` files left behind after their source file was
moved or deleted (warnings).
Folders Godot does not scan are skipped the same way: the project data
folder (``.godot/``), hidden files and folders, folders containing a
``.gdignore`` file and folders containing another ``project.godot``.
Addons are ordinary project files and are checked.
Exit codes: 0 no errors, 1 errors (or warnings with --strict),
2 usage problem or no project.godot found.
"""
from __future__ import annotations
import argparse
import json
import os
import posixpath
import re
import struct
import sys
from dataclasses import dataclass, field
from pathlib import Path
TOOL = "check_godot_project"
SCHEMA = 1
TEXT_RESOURCE_EXTS = {".tscn", ".tres"}
SCRIPT_CALL_RE = re.compile(
r"""\b(preload|load|load_threaded_request|change_scene_to_file)\s*\(\s*(["'])((?:res|uid)://[^"'\n]*)\2\s*\)"""
)
EXTENDS_RE = re.compile(r"""^\s*extends\s+(["'])((?:res|uid)://[^"'\n]*)\1""")
TOKEN_RE = re.compile(r'"|\n|[\[\](){}]|\b(?:ExtResource|SubResource)\b')
REF_RE = re.compile(r'(ExtResource|SubResource)\s*\(\s*(?:"((?:[^"\\]|\\.)*)"|(\d+))\s*\)')
PROJECT_STRING_RE = re.compile(r'"((?:res|uid)://(?:[^"\\]|\\.)*)"')
@dataclass
class Issue:
severity: str # "error" or "warning"
code: str
file: str
line: int | None
message: str
fix: str
def as_dict(self) -> dict:
return {
"code": self.code,
"file": self.file,
"line": self.line,
"message": self.message,
"fix": self.fix,
}
# ---------------------------------------------------------------- text helpers
def read_text(path: Path) -> str:
"""Read a text file, tolerating a UTF-8 BOM, CRLF line endings and bad bytes."""
data = path.read_bytes()
text = data.decode("utf-8", errors="replace")
if text.startswith("\ufeff"):
text = text[1:]
return text.replace("\r\n", "\n").replace("\r", "\n")
def unescape(value: str) -> str:
out = []
i = 0
while i < len(value):
ch = value[i]
if ch == "\\" and i + 1 < len(value):
nxt = value[i + 1]
out.append({"n": "\n", "t": "\t", "r": "\r"}.get(nxt, nxt))
i += 2
else:
out.append(ch)
i += 1
return "".join(out)
def parse_header(line: str) -> tuple[str, dict[str, str]]:
"""Parse ``[tag key=value ...]`` into a tag name and raw attribute values.
Quoted values are unescaped; other values (``ExtResource("1")``,
``PackedStringArray(...)``, numbers) are kept as written.
"""
body = line.strip()
if body.startswith("["):
body = body[1:]
if body.endswith("]"):
body = body[:-1]
i = 0
n = len(body)
while i < n and not body[i].isspace():
i += 1
tag = body[:i]
attrs: dict[str, str] = {}
while i < n:
while i < n and body[i].isspace():
i += 1
start = i
while i < n and body[i] not in "= \t":
i += 1
key = body[start:i]
if i >= n or body[i] != "=":
break
i += 1
if i < n and body[i] == '"':
j = i + 1
while j < n and body[j] != '"':
j += 2 if body[j] == "\\" else 1
attrs[key] = unescape(body[i + 1 : j])
i = j + 1
continue
start = i
depth = 0
while i < n:
ch = body[i]
if ch == '"':
i += 1
while i < n and body[i] != '"':
i += 2 if body[i] == "\\" else 1
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch.isspace() and depth <= 0:
break
i += 1
attrs[key] = body[start:i]
return tag, attrs
def ref_id(raw: str | None) -> str | None:
"""Return the id inside ``ExtResource("id")`` / ``SubResource("id")``."""
if not raw:
return None
m = REF_RE.match(raw.strip())
if not m:
return None
return unescape(m.group(2)) if m.group(2) is not None else m.group(3)
@dataclass
class Section:
tag: str
attrs: dict[str, str]
line: int
offset: int
@dataclass
class Ref:
kind: str # ExtResource or SubResource
rid: str
line: int
offset: int
@dataclass
class ParsedResource:
sections: list[Section] = field(default_factory=list)
refs: list[Ref] = field(default_factory=list)
def string_end(text: str, pos: int) -> int:
"""Index just past the closing quote of a string whose body starts at ``pos``.
Uses plain ``find`` instead of a regex so multi-megabyte strings (built-in
scripts, shader code) are scanned quickly and without extra memory.
"""
while True:
quote = text.find('"', pos)
if quote == -1:
return len(text)
backslashes = 0
k = quote - 1
while k >= pos and text[k] == "\\":
backslashes += 1
k -= 1
if backslashes % 2 == 0:
return quote + 1
pos = quote + 1
def parse_text_resource(text: str) -> ParsedResource:
"""Split a .tscn/.tres file into section headers and resource references.
String literals (including multi-line built-in script sources) are
skipped, so text inside strings is never mistaken for a header or a
reference. A header is a ``[`` at the start of a line while no bracket
is open.
"""
result = ParsedResource()
line = 1
depth = 0
pos = 0
at_line_start = True
n = len(text)
while pos < n:
if at_line_start and depth == 0:
stripped = pos
while stripped < n and text[stripped] in " \t":
stripped += 1
if stripped < n and text[stripped] == "[":
end = text.find("\n", stripped)
end = n if end == -1 else end
tag, attrs = parse_header(text[stripped:end])
result.sections.append(Section(tag, attrs, line, stripped))
elif stripped < n and text[stripped] == ";":
end = text.find("\n", stripped)
pos = n if end == -1 else end
at_line_start = False
continue
at_line_start = False
m = TOKEN_RE.search(text, pos)
if not m:
break
tok = m.group(0)
start = m.start()
if tok == '"':
end = string_end(text, start + 1)
line += text.count("\n", start, end)
pos = end
elif tok == "\n":
line += 1
pos = m.end()
at_line_start = True
elif tok in "([{":
depth += 1
pos = m.end()
elif tok in ")]}":
depth = max(0, depth - 1)
pos = m.end()
else:
rm = REF_RE.match(text, start)
if rm:
rid = unescape(rm.group(2)) if rm.group(2) is not None else rm.group(3)
result.refs.append(Ref(rm.group(1), rid, line, start))
line += text.count("\n", start, rm.end())
pos = rm.end()
else:
pos = m.end()
return result
UID_CHARS = "abcdefghijklmnopqrstuvwxy012345678" # Godot's uid alphabet ('z' and '9' are never used)
def uid_to_text(value: int) -> str:
digits = ""
while True:
digits = UID_CHARS[value % len(UID_CHARS)] + digits
value //= len(UID_CHARS)
if not value:
return "uid://" + digits
def read_binary_uid(path: Path) -> str | None:
"""Return the uid stored in a binary resource (.res, .scn, ...).
Returns ``"compressed"`` for compressed binary resources (magic ``RSCC``),
whose header this tool cannot read, and None when the file is not a binary
resource or has no uid.
"""
try:
with open(path, "rb") as handle:
head = handle.read(4096)
except OSError:
return None
if head[:4] == b"RSCC":
return "compressed"
if head[:4] != b"RSRC" or len(head) < 28:
return None
try:
order = ">" if struct.unpack_from("<I", head, 4)[0] else "<"
(type_len,) = struct.unpack_from(order + "I", head, 24)
_metadata_offset, flags, uid = struct.unpack_from(order + "QIQ", head, 28 + type_len)
except struct.error:
return None
if not flags & 2 or uid >= 1 << 63: # 2 = FORMAT_FLAG_UIDS; larger values mean "no uid"
return None
return uid_to_text(uid)
def strip_comment(code_line: str) -> str:
"""Drop a GDScript ``#`` comment that is not inside a string."""
quote = None
i = 0
while i < len(code_line):
ch = code_line[i]
if quote:
if ch == "\\":
i += 1
elif ch == quote:
quote = None
elif ch in "\"'":
quote = ch
elif ch == "#":
return code_line[:i]
i += 1
return code_line
# ------------------------------------------------------------------- project
@dataclass
class PathStatus:
status: str # ok, case, missing, ignored, unknown
actual: str | None = None # project-relative path found on disk
class Project:
def __init__(self, root: Path) -> None:
self.root = root
self.data_dir = ".godot"
self.files: list[str] = [] # scanned, project-relative posix paths
self.file_set: set[str] = set()
self.by_name: dict[str, list[str]] = {}
self._listing: dict[str, dict[str, bool]] = {}
self._skip_cache: dict[str, bool] = {}
self.issues: list[Issue] = []
self.files_checked = 0
self.uid_to_files: dict[str, list[tuple[str, int | None]]] = {}
self.file_uid: dict[str, str] = {}
self.orphan_uids: dict[str, str] = {} # uid -> orphaned sidecar file
self._uid_owner_file: dict[tuple[str, str], str] = {} # (uid, source) -> file holding the uid
self.generated: set[str] = set() # files listed as importer output in .import files
# Compressed binary resources (.scn/.res saved by the editor) keep their uid in
# zstd-compressed data this tool cannot read, so an unknown uid may belong to one.
self.hidden_uid_files: list[str] = []
self.parsed: dict[str, ParsedResource] = {}
self.unreadable: set[str] = set() # files that exist but could not be read
self.unlistable: set[str] = set() # directories that exist but could not be listed
self._trees: dict[str, tuple[set[str], set[str]] | None] = {}
self._tree_busy: set[str] = set()
# -- reporting
def add(self, severity: str, code: str, file: str, line: int | None, message: str, fix: str) -> None:
self.issues.append(Issue(severity, code, file, line, message, fix))
def add_unreadable(self, rel: str) -> None:
self.add("warning", "file-unreadable", rel, None, "File could not be read, so it was not checked.",
"Check the file's permissions (on Windows, also whether the path is longer than 260 characters).")
# -- filesystem
def listing(self, rel_dir: str) -> dict[str, bool]:
"""Names in a project directory mapped to whether each is a directory.
Only call it for directories. Broken symlinks and special files are left out,
so a reference to one counts as missing. A directory that exists but cannot be
listed (permissions, a path too long for Windows) is recorded in ``unlistable``.
"""
if rel_dir not in self._listing:
entries: dict[str, bool] = {}
try:
with os.scandir(self.root / rel_dir if rel_dir else self.root) as it:
for entry in it:
try:
if entry.is_dir():
entries[entry.name] = True
elif entry.is_file():
entries[entry.name] = False
except OSError:
pass
except OSError:
self.unlistable.add(rel_dir)
self._listing[rel_dir] = entries
return self._listing[rel_dir]
def skipped_dir(self, rel_dir: str) -> bool:
"""True when Godot's file scan skips this directory (and so everything in it)."""
if rel_dir in self._skip_cache:
return self._skip_cache[rel_dir]
skip = False
if rel_dir:
name = rel_dir.rsplit("/", 1)[-1]
parent = rel_dir.rsplit("/", 1)[0] if "/" in rel_dir else ""
if name.startswith(".") or rel_dir == self.data_dir or self.skipped_dir(parent):
skip = True
else:
names = self.listing(rel_dir)
skip = ".gdignore" in names or "project.godot" in names
self._skip_cache[rel_dir] = skip
return skip
def scan(self) -> None:
# Each stack entry carries the identities of the folders above it, so a symlink
# that points back up the tree is entered once instead of looping forever.
stack: list[tuple[str, tuple]] = [("", ())]
while stack:
rel_dir, ancestors = stack.pop()
try:
info = os.stat(self.root / rel_dir if rel_dir else self.root)
# Some file systems (FAT on Windows) report inode 0 for everything.
identity = (info.st_dev, info.st_ino) if info.st_ino else None
except OSError:
identity = None
if identity is not None:
if identity in ancestors:
continue
ancestors = ancestors + (identity,)
for name, is_dir in sorted(self.listing(rel_dir).items()):
rel = f"{rel_dir}/{name}" if rel_dir else name
if is_dir:
if not self.skipped_dir(rel):
stack.append((rel, ancestors))
elif not name.startswith("."):
self.files.append(rel)
if rel_dir in self.unlistable:
self.add(
"warning",
"dir-unreadable",
rel_dir,
None,
"Folder could not be read, so files in it were not checked and references "
"into it are not verified.",
"Check the folder's permissions (on Windows, also whether the path is longer than 260 characters).",
)
self.files.sort()
self.file_set = set(self.files)
for rel in self.files:
self.by_name.setdefault(rel.rsplit("/", 1)[-1].casefold(), []).append(rel)
def resolve(self, raw: str, base_dir: str = "") -> PathStatus:
"""Look up a ``res://`` (or file-relative) path on disk, case-sensitively.
Backslashes and repeated slashes are normalized the way Godot's
``simplify_path()`` does, so ``res://art\\icon.png`` finds ``art/icon.png``.
"""
path = raw.split("::", 1)[0].replace("\\", "/")
if path.startswith("res://"):
path = path[len("res://") :].lstrip("/")
elif "://" in path:
return PathStatus("unknown")
else:
path = posixpath.join(base_dir, path) if base_dir else path
path = posixpath.normpath(path) if path else ""
if path in ("", "."):
return PathStatus("ok", "")
if path.startswith("../") or path == "..":
return PathStatus("missing")
if path == self.data_dir or path.startswith(self.data_dir + "/"):
return PathStatus("unknown")
if path in self.generated:
return PathStatus("unknown")
parts = path.split("/")
current = ""
case_differs = False
for index, part in enumerate(parts):
if current in self.unlistable:
return PathStatus("unknown")
names = self.listing(current)
if part in names:
actual = part
else:
matches = [n for n in names if n.casefold() == part.casefold()]
if len(matches) != 1:
return PathStatus("missing")
actual = matches[0]
case_differs = True
if index < len(parts) - 1 and not names[actual]:
return PathStatus("missing") # a file used as a folder
current = f"{current}/{actual}" if current else actual
if case_differs:
return PathStatus("case", current)
parent = current.rsplit("/", 1)[0] if "/" in current else ""
name = current.rsplit("/", 1)[-1]
if self.skipped_dir(parent) or name.startswith(".") or (
self.listing(parent).get(name) and self.skipped_dir(current)
):
return PathStatus("ignored", current)
return PathStatus("ok", current)
def similar(self, raw: str) -> list[str]:
name = raw.split("::", 1)[0].replace("\\", "/").rstrip("/").rsplit("/", 1)[-1].casefold()
return self.by_name.get(name, [])[:3]
def missing_fix(self, raw: str, what: str) -> str:
candidates = self.similar(raw)
if candidates:
listed = ", ".join(f"res://{c}" for c in candidates)
return f"A file with that name exists at {listed}; point {what} there, or restore the file."
return f"Create the file, or point {what} at the file's current location."
# -- uids
def uid_target(self, uid: str) -> str | None:
entries = self.uid_to_files.get(uid)
return entries[0][0] if entries else None
def index_uids(self) -> None:
for rel in self.files:
lower = rel.lower()
if lower.endswith(".import"):
source = rel[: -len(".import")]
text = self.load(rel)
m = re.search(r'^uid="(uid://[^"]+)"', text, re.M)
if source not in self.file_set:
self.add(
"warning",
"import-file-orphaned",
rel,
None,
f"Import settings file has no source file res://{source}.",
"If the asset was moved, move this .import file with it; if it was deleted, delete this file.",
)
if m:
self.orphan_uids.setdefault(m.group(1), rel)
continue
if m:
line = text.count("\n", 0, m.start()) + 1
self._claim_uid(m.group(1), source, rel, line)
# Files the importer writes (for example .translation files from a CSV).
dest = re.search(r"^dest_files=\[(.*)\]\s*$", text, re.M)
if dest:
for out in re.findall(r'"res://([^"]+)"', dest.group(1)):
self.generated.add(out)
elif lower.endswith(".uid"):
source = rel[: -len(".uid")]
uid = self.load(rel).strip().split("\n", 1)[0].strip()
if source not in self.file_set:
self.add(
"warning",
"uid-file-orphaned",
rel,
1,
f"UID file has no source file res://{source}.",
"If the script/shader was moved, move this .uid file next to it; if it was deleted, delete this file.",
)
if uid.startswith("uid://"):
self.orphan_uids.setdefault(uid, rel)
continue
if uid.startswith("uid://") and source not in self.file_uid:
self._claim_uid(uid, source, rel, 1)
elif Path(rel).suffix.lower() in TEXT_RESOURCE_EXTS:
parsed = self.parse(rel)
if parsed.sections:
head = parsed.sections[0]
uid = head.attrs.get("uid", "")
if head.tag in ("gd_scene", "gd_resource") and uid.startswith("uid://"):
self._claim_uid(uid, rel, rel, head.line)
elif f"{rel}.import" not in self.file_set and f"{rel}.uid" not in self.file_set \
and Path(rel).suffix.lower() != ".gd":
uid = read_binary_uid(self.root / rel)
if uid == "compressed":
self.hidden_uid_files.append(rel)
elif uid:
self._claim_uid(uid, rel, rel, None)
for uid, entries in self.uid_to_files.items():
if len(entries) < 2:
continue
first_source = entries[0][0]
for source, line in entries[1:]:
owner = self._uid_owner_file.get((uid, source), source)
self.add(
"error",
"uid-duplicate",
owner,
line,
f"{uid} is also used by res://{first_source}; references by uid load only one of them.",
"Give one file a new uid: delete the uid=\"...\" attribute (or the .uid/.import file) of the copy "
"and open the project in the Godot editor so it assigns a fresh one, then re-save scenes that use it.",
)
def _claim_uid(self, uid: str, source: str, owner_file: str, line: int | None) -> None:
if source in self.file_uid:
return
self.file_uid[source] = uid
self.uid_to_files.setdefault(uid, []).append((source, line))
self._uid_owner_file[(uid, source)] = owner_file
def uid_knowable(self, rel: str) -> bool:
"""True when this tool can see the file's uid: a text resource header, a .import or a .uid file.
Binary resources (.res, .scn, .mesh, ...) keep their uid inside the binary data; it is
read from uncompressed ones, while compressed ones are not reported.
"""
return (
Path(rel).suffix.lower() in TEXT_RESOURCE_EXTS
or rel in self.file_uid
or f"{rel}.import" in self.file_set
or f"{rel}.uid" in self.file_set
)
# -- file cache
def load(self, rel: str) -> str:
"""Read a project file. Not cached: each file is read once, and big projects hold
gigabytes of .tres text that should not stay in memory."""
try:
return read_text(self.root / rel)
except OSError:
self.unreadable.add(rel)
return ""
def parse(self, rel: str) -> ParsedResource:
if rel not in self.parsed:
self.parsed[rel] = parse_text_resource(self.load(rel))
return self.parsed[rel]
# -- ext_resource resolution
def ext_target(self, rel: str, attrs: dict[str, str]) -> str | None:
"""The file Godot actually loads for an ``[ext_resource]``, or None."""
uid = attrs.get("uid", "")
target = self.uid_target(uid) if uid.startswith("uid://") else None
if target:
return target
path = attrs.get("path")
if path is None:
return None
st = self.resolve(path, posixpath.dirname(rel))
return st.actual if st.status == "ok" else None
# -- scene trees
def scene_tree(self, rel: str) -> tuple[set[str], set[str]] | None:
"""Node paths (relative to the root, root excluded) and opaque prefixes of a scene.
Returns None when the scene cannot be read as a text scene. An opaque
prefix is a node whose children come from a scene this tool cannot read;
``"."`` means the whole tree is opaque.
"""
if Path(rel).suffix.lower() != ".tscn" or rel not in self.file_set:
return None
if rel in self._trees:
return self._trees[rel]
if rel in self._tree_busy:
return None
self.parse(rel)
if rel in self.unreadable:
return None
self._tree_busy.add(rel)
try:
tree = self._walk_scene(rel, report=False)
finally:
self._tree_busy.discard(rel)
self._trees[rel] = tree
return tree
def _walk_scene(self, rel: str, report: bool) -> tuple[set[str], set[str]]:
parsed = self.parse(rel)
ext = {
s.attrs.get("id"): s.attrs
for s in parsed.sections
if s.tag == "ext_resource" and s.attrs.get("id") is not None
}
known: set[str] = set()
opaque: set[str] = set()
roots: list[Section] = []
broken: set[str] = set() # names of nodes whose parent= is wrong
def merge(prefix: str, raw_instance: str | None) -> None:
rid = ref_id(raw_instance)
target = self.ext_target(rel, ext[rid]) if rid in ext else None
sub = self.scene_tree(target) if target else None
if sub is None:
opaque.add(prefix or ".")
return
sub_known, sub_opaque = sub
for p in sub_known:
known.add(f"{prefix}/{p}" if prefix else p)
for p in sub_opaque:
if p == ".":
opaque.add(prefix or ".")
else:
opaque.add(f"{prefix}/{p}" if prefix else p)
for sec in parsed.sections:
if sec.tag == "node":
name = sec.attrs.get("name")
parent = sec.attrs.get("parent")
if parent is None:
roots.append(sec)
if report and len(roots) == 2:
self.add(
"error",
"scene-multiple-roots",
rel,
sec.line,
f'Node "{name}" has no parent= but the scene already has a root node '
f'"{roots[0].attrs.get("name")}" (line {roots[0].line}).',
'Give this node a parent= path (use parent="." for a child of the root); '
"a scene must have exactly one root.",
)
if len(roots) == 1 and "instance" in sec.attrs:
merge("", sec.attrs.get("instance"))
continue
ppath = self._norm_node_path(parent)
if ppath is not None and ppath != "" and ppath not in known and self._verifiable(ppath, opaque):
# Keep going as if the parent existed, so one bad parent= is reported once
# instead of again for every child and connection of this node.
if name:
broken.add(name)
if report and "parent_id_path" in sec.attrs:
# Godot 4.6+ finds a renamed parent by its unique ids when the path fails.
self.add(
"warning",
"node-parent-missing",
rel,
sec.line,
f'Node "{name}" has parent="{parent}", which no longer exists; Godot looks the '
"parent up by parent_id_path instead, which works while the base scene keeps its node ids.",
"Open and re-save this scene in the Godot editor so it writes the current parent= path.",
)
elif report:
self.add(
"error",
"node-parent-missing",
rel,
sec.line,
f'Node "{name}" has parent="{parent}", but no node at that path is declared above it.',
"Fix the parent= path (relative to the root, without the root's name; "
'use "." for direct children) or declare the parent node earlier in the file.',
)
if name is None or ppath is None:
continue
full = f"{ppath}/{name}" if ppath else name
known.add(full)
if "instance" in sec.attrs:
merge(full, sec.attrs.get("instance"))
elif "instance_placeholder" in sec.attrs:
opaque.add(full)
elif sec.tag == "connection" and report:
for key in ("from", "to"):
value = sec.attrs.get(key)
if value is None:
continue
npath = self._norm_node_path(value)
if npath is None or npath == "" or npath in known or not self._verifiable(npath, opaque):
continue
if broken.intersection(npath.split("/")):
continue # already reported as node-parent-missing
if f"{key}_uid_path" in sec.attrs:
# Godot 4.6+ finds a renamed node by its unique ids when the path fails.
self.add(
"warning",
"connection-node-missing",
rel,
sec.line,
f'Signal connection {key}="{value}" names a node path that no longer exists; Godot '
f"looks the node up by {key}_uid_path instead, which works while the node ids stay the same.",
"Open and re-save this scene in the Godot editor so it writes the current node path.",
)
continue
self.add(
"error",
"connection-node-missing",
rel,
sec.line,
f'Signal connection {key}="{value}" names a node that does not exist in this scene; '
"Godot drops the connection.",
f"Update {key}= to the node's current path (relative to the root, \".\" for the root), "
"or remove the [connection] line.",
)
if report and not roots:
self.add(
"error",
"scene-root-missing",
rel,
parsed.sections[0].line if parsed.sections else None,
"Scene has no root node (every [node] needs a parent, or there are no nodes).",
"Add one [node] without parent= as the first node; all other nodes use parent= paths below it.",
)
return known, opaque
@staticmethod
def _norm_node_path(value: str) -> str | None:
"""Normalize a scene-relative node path; None when it cannot be checked."""
value = value.strip()
if value in (".", ""):
return ""
if ".." in value.split("/") or value.startswith("/") or "%" in value or ":" in value:
return None
if value.startswith("./"):
value = value[2:]
return value.strip("/")
@staticmethod
def _verifiable(path: str, opaque: set[str]) -> bool:
if "." in opaque:
return False
return not any(path.startswith(o + "/") for o in opaque)
# ----------------------------------------------------------- the checks
def check_text_resource(self, rel: str) -> None:
self.files_checked += 1
parsed = self.parse(rel)
base_dir = posixpath.dirname(rel)
sections = parsed.sections
if rel in self.unreadable:
self.add_unreadable(rel)
return
if not sections or sections[0].tag not in ("gd_scene", "gd_resource"):
self.add(
"error",
"header-missing",
rel,
sections[0].line if sections else 1,
"File does not start with a [gd_scene ...] or [gd_resource ...] header, so Godot cannot load it.",
'Start the file with [gd_scene format=3] (scenes) or [gd_resource type="..." format=3] (resources).',
)
return
ext_ids: dict[str, int] = {}
sub_ids: dict[str, tuple[int, int]] = {}
for sec in sections:
if sec.tag == "ext_resource":
missing = [k for k in ("path", "type", "id") if k not in sec.attrs]
if missing:
self.add(
"error",
"ext-resource-malformed",
rel,
sec.line,
f"[ext_resource] is missing {', '.join(missing)}; Godot refuses to load the file.",
'Write it as [ext_resource type="Type" uid="uid://..." path="res://file" id="1_abc"].',
)
rid = sec.attrs.get("id")
if rid is not None:
if rid in ext_ids:
self.add(
"error",
"resource-id-duplicate",
rel,
sec.line,
f'ext_resource id "{rid}" is already declared on line {ext_ids[rid]}.',
"Give each [ext_resource] a unique id and update the ExtResource(...) uses.",
)
else:
ext_ids[rid] = sec.line
if "path" in sec.attrs:
self._check_ext_resource(rel, base_dir, sec)
elif sec.tag == "sub_resource":
rid = sec.attrs.get("id")
missing = [k for k in ("type", "id") if k not in sec.attrs]
if missing:
self.add(
"error",
"sub-resource-malformed",
rel,
sec.line,
f"[sub_resource] is missing {', '.join(missing)}; Godot refuses to load the file.",
'Write it as [sub_resource type="Type" id="Type_abc12"].',
)
if rid is not None and rid in sub_ids:
self.add(
"error",
"resource-id-duplicate",
rel,
sec.line,
f'sub_resource id "{rid}" is already declared on line {sub_ids[rid][0]}.',
"Give each [sub_resource] a unique id and update the SubResource(...) uses.",
)
elif rid is not None:
sub_ids[rid] = (sec.line, sec.offset)
elif sec.tag == "node" and "instance_placeholder" in sec.attrs:
raw = sec.attrs["instance_placeholder"]
self._check_plain_path(rel, sec.line, raw, base_dir, "error", "placeholder-path-missing",
"instance_placeholder=")
for ref in parsed.refs:
if ref.kind == "ExtResource":
if ref.rid not in ext_ids:
self.add(
"error",
"ext-resource-id-undeclared",
rel,
ref.line,
f'ExtResource("{ref.rid}") is used but no [ext_resource] has id="{ref.rid}".',
"Add the matching [ext_resource] near the top of the file, or change the id to a declared one.",
)
else:
decl = sub_ids.get(ref.rid)
if decl is None:
self.add(
"error",
"sub-resource-id-undeclared",
rel,
ref.line,
f'SubResource("{ref.rid}") is used but no [sub_resource] has id="{ref.rid}".',
"Add the matching [sub_resource] above this line, or change the id to a declared one.",
)
elif decl[1] > ref.offset:
self.add(
"error",
"sub-resource-used-before-declared",
rel,
ref.line,
f'SubResource("{ref.rid}") is used before its [sub_resource] on line {decl[0]}.',
"Move that [sub_resource] block above the first place that uses it.",
)
if sections[0].tag == "gd_scene":
self._walk_scene(rel, report=True)
def _check_ext_resource(self, rel: str, base_dir: str, sec: Section) -> None:
path = sec.attrs["path"]
uid = sec.attrs.get("uid", "")
has_uid = uid.startswith("uid://")
target = self.uid_target(uid) if has_uid else None
st = self.resolve(path, base_dir)
if st.status == "unknown":
return
if target:
if st.status == "ok" and st.actual == target:
return
if st.status == "ok":
self.add(
"warning",
"uid-path-mismatch",
rel,
sec.line,
f'ext_resource uid {uid} belongs to res://{target}, but path="{path}" names a different file. '
f"Godot loads res://{target}.",
f'If res://{target} is right, set path="res://{target}". Otherwise set uid= to '
f"{self.file_uid.get(st.actual or '', 'the uid of the intended file')} "
"or delete the uid= attribute.",
)
else:
self.add(
"warning",
"ext-resource-path-stale",
rel,
sec.line,
f'path="{path}" is out of date; Godot finds the file by uid at res://{target}.',
f'Set path="res://{target}" (the editor also fixes it when the scene is re-saved).',
)
return
if st.status == "ok":
if has_uid and st.actual and (self.uid_knowable(st.actual) or uid in self.orphan_uids):
actual_uid = self.file_uid.get(st.actual)
orphan = self.orphan_uids.get(uid)
if orphan:
fix = (f"The uid is in the orphaned file {orphan}; move it next to res://{st.actual} "
f"(as {Path(st.actual).name}{Path(orphan).suffix}) or set uid= to the file's own uid.")
elif actual_uid:
fix = f'Set uid="{actual_uid}" (the uid of res://{st.actual}).'
else:
fix = ("Delete the uid= attribute, or open the project in the Godot editor and re-save this "
"file so it writes the correct uid.")
self.add(
"warning",
"ext-resource-uid-unknown",
rel,
sec.line,
f"uid {uid} is not assigned to any file; Godot warns and falls back to path=\"{path}\".",
fix,
)
return
if st.status == "case":
self.add(
"error",
"path-case-mismatch",
rel,
sec.line,
f'path="{path}" differs in letter case from the file on disk (res://{st.actual}). '
"It works on Windows/macOS but breaks on Linux and in exported games.",
f'Set path="res://{st.actual}".',
)
return
if st.status == "ignored":
self.add(
"warning",
"path-in-ignored-dir",
rel,
sec.line,
f'path="{path}" points into a folder Godot does not scan (.gdignore, hidden or nested project); '
"it is not imported or exported.",
"Move the file into a scanned folder, or remove the .gdignore file.",
)
return
hint = ""
if has_uid and uid in self.orphan_uids:
hint = f" Its uid is in the orphaned file {self.orphan_uids[uid]}."
suffix = Path(path).suffix.lower()
hidden = [f for f in self.hidden_uid_files if Path(f).suffix.lower() == suffix] if has_uid else []
if hidden:
# The file may have been moved: Godot would still find it by uid, which is
# stored inside compressed binary files this tool cannot read.
listed = ", ".join(f"res://{f}" for f in hidden[:3]) + (" ..." if len(hidden) > 3 else "")
self.add(
"warning",
"ext-resource-missing",
rel,
sec.line,
f'ext_resource path="{path}" does not exist. Godot still loads it if uid {uid} belongs to '
f"one of the compressed binary files whose uid this tool cannot read ({listed}).",
"If the Godot editor reports no missing dependency for this file, re-save it there to update "
"path=. Otherwise: " + self.missing_fix(path, "path="),
)
return
self.add(
"error",
"ext-resource-missing",
rel,
sec.line,
f'ext_resource path="{path}" does not exist'
+ (f" and uid {uid} matches no file" if has_uid else "")
+ "; the resource fails to load."
+ hint,
self.missing_fix(path, "path="),
)
def _check_plain_path(self, rel: str, line: int | None, raw: str, base_dir: str, severity: str,
code: str, what: str, case_severity: str = "error") -> None:
"""Check one res:// or uid:// value that is not an [ext_resource]."""
if raw.startswith("uid://"):
if raw not in self.uid_to_files:
hint = f" (it is in the orphaned file {self.orphan_uids[raw]})" if raw in self.orphan_uids else ""
if self.hidden_uid_files and not hint:
self.add("warning", code, rel, line,
f"{what} {raw} matches no file this tool can read; it may belong to one of the "
f"{len(self.hidden_uid_files)} compressed binary files (.scn/.res) whose uid is unreadable.",
"Check in the Godot editor that this uid opens the intended file, or use the file's "
"res:// path instead.")
return
self.add(severity, code, rel, line, f"{what} {raw} matches no file in the project{hint}.",
"Use the res:// path of the file, or the uid shown in the Godot editor for it.")
return
st = self.resolve(raw, base_dir)
if st.status == "missing" and ":" in raw.rsplit("/", 1)[-1]:
# Translation remaps store "res://file.ext:locale".
raw = raw.rsplit(":", 1)[0]
st = self.resolve(raw, base_dir)
if st.status in ("ok", "unknown"):
return
if st.status == "case":
self.add(case_severity, "path-case-mismatch", rel, line,
f'{what} "{raw}" differs in letter case from the file on disk (res://{st.actual}). '
"It works on Windows/macOS but breaks on Linux and in exported games.",
f'Use "res://{st.actual}".')
elif st.status == "ignored":
self.add("warning", "path-in-ignored-dir", rel, line,
f'{what} "{raw}" points into a folder Godot does not scan (.gdignore, hidden or nested project).',
"Move the file into a scanned folder, or remove the .gdignore file.")
else:
self.add(severity, code, rel, line, f'{what} "{raw}" does not exist.', self.missing_fix(raw, what))
def check_project_file(self) -> None:
rel = "project.godot"
self.files_checked += 1
text = self.load(rel)
section = ""
setting = "" # the setting whose (possibly multi-line) value this line belongs to
main_scene_seen = False
for lineno, line in enumerate(text.split("\n"), start=1):
stripped = line.strip()
if re.fullmatch(r"\[[A-Za-z0-9_./-]+\]", stripped):
section = stripped[1:-1]
setting = ""
continue
if section == "locale":
# Left over from Godot 3; Godot 4 reads internationalization/locale/* instead.
continue
m = re.match(r'^([A-Za-z0-9_./"-][^=]*?)=(.*)$', line)
key = ""
value = ""
if m and not line.startswith(('"', " ", "\t")):
key = m.group(1).strip()
value = m.group(2).strip()
setting = f"{section}/{key}" if section else key
string_value = re.fullmatch(r'"((?:[^"\\]|\\.)*)"', value) if key else None
if section == "application" and re.fullmatch(r"run/main_scene(\.[\w.]+)?", key):
scene = unescape(string_value.group(1)) if string_value else ""
if key == "run/main_scene" and scene:
main_scene_seen = True
if scene:
self._check_plain_path(rel, lineno, scene, "", "error", "main-scene-missing",
f"application/{key}")
continue
if section == "autoload" and key:
if string_value:
target = unescape(string_value.group(1)).lstrip("*")
self._check_plain_path(rel, lineno, target, "", "error", "autoload-missing",
f"autoload {key}")
continue
if string_value and string_value.group(1).startswith("uid://"):
# A uid is only checked when it is the whole value of a setting.
self._check_plain_path(rel, lineno, unescape(string_value.group(1)), "", "warning",
"project-path-missing", f"setting {setting}")
continue
for sm in PROJECT_STRING_RE.finditer(line):
raw = unescape(sm.group(1))
if raw.startswith("res://"):
self._check_plain_path(rel, lineno, raw, "", "warning", "project-path-missing",
f"setting {setting}", case_severity="warning")
if not main_scene_seen:
self.add("warning", "main-scene-not-set", rel, None,
"application/run/main_scene is not set; the exported game has no scene to start.",
"Set it in Project Settings > Application > Run > Main Scene "
'(run/main_scene="res://main.tscn" under [application]).')
def check_script(self, rel: str) -> None:
self.files_checked += 1
text = self.load(rel)
base_dir = posixpath.dirname(rel)
if rel in self.unreadable:
self.add_unreadable(rel)
return
for lineno, line in enumerate(text.split("\n"), start=1):
if "://" not in line:
continue
code = strip_comment(line)
m = EXTENDS_RE.match(code)
if m:
self._check_plain_path(rel, lineno, m.group(2), base_dir, "warning", "script-path-missing",
"extends")
for m in SCRIPT_CALL_RE.finditer(code):
self._check_plain_path(rel, lineno, m.group(3), base_dir, "warning", "script-path-missing",
f"{m.group(1)}()")
def read_project_settings(self) -> None:
text = self.load("project.godot")
if re.search(r"^config/use_hidden_project_data_directory\s*=\s*false\s*$", text, re.M):
self.data_dir = "godot"
def run(self) -> None:
self.read_project_settings()
self.scan()
self.index_uids()
self.check_project_file()
for rel in self.files:
suffix = Path(rel).suffix.lower()
if suffix in TEXT_RESOURCE_EXTS:
self.check_text_resource(rel)
elif suffix == ".gd":
self.check_script(rel)
elif suffix in (".import", ".uid"):
self.files_checked += 1
# ----------------------------------------------------------------------- CLI
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="check_godot_project.py",
description=(
"Read-only check of a Godot 4.x project for broken references: missing or "
"wrong-case res:// paths, unknown or duplicate uids, undeclared ExtResource/SubResource "
"ids, broken scene trees and signal connections, project.godot main scene and "
"autoloads, and literal preload()/load() paths in .gd scripts."
),
epilog=(
"example:\n"
" python scripts/check_godot_project.py path/to/my_game\n"
" python scripts/check_godot_project.py . --json --max-issues 20\n\n"
"exit codes: 0 no errors, 1 errors found (or warnings with --strict), "
"2 usage problem or no project.godot"
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("project_dir", nargs="?", default=".", metavar="PROJECT_DIR",
help="folder that contains project.godot (default: current folder)")
parser.add_argument("--json", action="store_true", help="print one JSON object instead of text")
parser.add_argument("--strict", action="store_true", help="exit 1 when there are warnings too")
parser.add_argument("--max-issues", type=int, default=50, metavar="N",
help="print at most N issues, errors first (default: 50; 0 = no limit)")
return parser
def main(argv: list[str] | None = None) -> int:
# A console or pipe that cannot encode a file name (Windows code pages) must not crash the report.
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(errors="backslashreplace")
parser = build_parser()
args = parser.parse_args(argv)
if args.max_issues < 0:
parser.error("--max-issues must be 0 or more")
root = Path(args.project_dir).expanduser()
if not root.is_dir():
print(f"error: {args.project_dir} is not a folder", file=sys.stderr)
return 2
if not (root / "project.godot").is_file():
print(f"error: {args.project_dir} is not a Godot project (no project.godot found there)",
file=sys.stderr)
return 2
root = root.resolve()
project = Project(root)
project.run()
def sort_key(issue: Issue) -> tuple:
return (issue.file, issue.line or 0, issue.code, issue.message)
errors = sorted((i for i in project.issues if i.severity == "error"), key=sort_key)
warnings = sorted((i for i in project.issues if i.severity == "warning"), key=sort_key)
ordered = errors + warnings
shown = ordered[: args.max_issues] if args.max_issues else ordered
truncated = len(ordered) > len(shown)
if args.json:
payload = {
"tool": TOOL,
"schema": SCHEMA,
"root": root.as_posix(),
"files_checked": project.files_checked,
"error_count": len(errors),
"warning_count": len(warnings),
"errors": [i.as_dict() for i in shown if i.severity == "error"],
"warnings": [i.as_dict() for i in shown if i.severity == "warning"],
"truncated": truncated,
}
print(json.dumps(payload, indent=2))
else:
for issue in shown:
where = f"{issue.file}:{issue.line}" if issue.line else issue.file
print(f"{issue.severity.upper()} {where}: {issue.message}")
print(f" fix: {issue.fix}")
if truncated:
print(f"... and {len(ordered) - len(shown)} more")
print(f"Summary: {len(errors)} errors, {len(warnings)} warnings in {project.files_checked} files checked")
if errors or (args.strict and warnings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
SKILL.md›
---
name: godot-nodes-scenes
description: >
Structure a Godot 4.7 project with the scene tree and node composition: build
reusable scenes, instance PackedScenes at runtime, navigate the tree safely,
and register autoload singletons. Use when designing .tscn scenes, deciding how
to split nodes, spawning instances with instantiate(), wiring autoloads, or
fixing "node not found"/freed-node errors in a Godot project.
---
# Godot Nodes & Scenes (4.x)
Compose games from nodes and scenes, instance them at runtime, and access the tree
without crashing on freed or missing nodes. Targets **Godot 4.7**.
## When to use
- Use when structuring `.tscn` scenes, choosing how to break a feature into nodes,
instancing a `PackedScene` (bullets, enemies, UI), or setting up autoload singletons.
- Use when debugging `get_node()` / `$Path` returning `null`, or "Attempt to call on a
previously freed instance".
**When _not_ to use:** GDScript language/syntax → `godot-gdscript`; signal-based
decoupling → `godot-signals-groups`; physics bodies/collisions → `godot-physics`.
## Core workflow
1. **Model with composition.** A scene is a tree of nodes saved as `.tscn`. Build small,
single-purpose scenes (Player, Bullet, Enemy) and compose larger scenes from them.
Favor adding child nodes over deep inheritance.
2. **Make a scene reusable** by giving its root a script and exposing `@export` config.
Save it; it becomes a `PackedScene` you can instance many times.
3. **Instance at runtime** with `preload`/`load` → `scene.instantiate()` →
`add_child(instance)`. Set position/state _after_ adding (or before, both work).
4. **Access nodes safely.** Use `@onready var x = $Path` for fixed children; use unique
names (`%Name`) for nodes deep in the tree; never assume a node still exists.
5. **Use autoloads for global state/services** (game state, audio, scene switching) —
registered in Project Settings > Globals (Autoload), accessible by name everywhere.
6. **Free nodes with `queue_free()`** and guard later access with `is_instance_valid()`.
7. **Check references before you finish.** After creating, moving or renaming scenes,
resources or scripts, run `python scripts/check_godot_project.py <project-dir>` (no
Godot install needed; `--json` for machine-readable output). Fix every `ERROR` (missing
or wrong-case `res://` paths, undeclared `ExtResource`/`SubResource` ids, bad `parent=`
paths, missing main scene or autoload) and re-run until it exits 0. Warnings such as a
stale `path=` that Godot still finds by uid are safe but worth fixing. Run the script
path relative to this skill directory, or resolve the installed skill path first. It
needs Python 3.10+ and nothing else, and it does not replace opening the project in Godot.
## Patterns
### 1. Instance a scene at runtime
```gdscript
extends Node2D
const BULLET := preload("res://bullet.tscn") # preload: loaded at compile time
func shoot(at: Vector2, dir: Vector2) -> void:
var bullet := BULLET.instantiate() # create an instance of the scene
bullet.global_position = at
bullet.direction = dir # set exported/public state
add_child(bullet) # now it's in the tree and runs
```
### 2. Safe node access: $, get_node_or_null, and unique names
```gdscript
@onready var label: Label = $UI/Label # $ is sugar for get_node("UI/Label")
@onready var health_bar: ProgressBar = %HealthBar # % = scene-unique name (rename-proof)
func update() -> void:
var optional := get_node_or_null("Maybe/Missing") # returns null instead of erroring
if optional:
optional.queue_free()
```
### 3. An autoload singleton (global game state)
```gdscript
# game_state.gd — add in Project Settings > Globals > Autoload as "GameState".
extends Node
var score := 0
signal score_changed(value: int)
func add_score(points: int) -> void:
score += points
score_changed.emit(score) # any scene can: GameState.score_changed.connect(...)
```
### 4. Change the running scene
```gdscript
func go_to_level_2() -> void:
# Swaps the current scene for another. Frees the old scene tree.
get_tree().change_scene_to_file("res://levels/level_2.tscn")
# Or, with a preloaded PackedScene:
# get_tree().change_scene_to_packed(LEVEL_2)
```
## Pitfalls
- **`$Path` / `get_node()` return `null` or error** when the path is wrong or the node
isn't in the tree yet. Use `@onready`, verify the path matches the scene, or use
`get_node_or_null()` for optional nodes.
- **Renaming a node breaks `$Path`.** Use **unique names** (`%Name`, set via right-click
> Access as Unique Name) so deep references survive renames and reparenting.
- **`free()` mid-frame can crash** other code still using the node. Prefer
`queue_free()` (deletes at end of frame) and check `is_instance_valid(node)`.
- **Setting child state before `add_child()`** is fine, but `_ready()` on the child only
runs _after_ it enters the tree — don't expect its `@onready` vars before that.
- **Autoload order matters**: autoloads are added before the main scene, in list order.
An autoload can't depend on the main scene existing yet.
- **`instance()` was renamed** to `instantiate()` in Godot 4. `preload` runs at parse
time (path must be constant); `load` runs at runtime (path can be a variable).
- **`change_scene_to_file()` is deferred**, not immediate — Godot swaps and frees the old
scene at the end of the current frame. Any code after the call still runs against the _old_
tree, and `get_tree().current_scene` isn't the new scene until next frame. Don't read the new
scene's nodes on the same line; do it from the new scene's `_ready()`.
## References
- For scene inheritance, `owner`/ownership when saving scenes from code, groups vs
unique names, and node-path edge cases, read `references/tree-and-instancing.md`.
- `scripts/check_godot_project.py` — read-only reference checker for a Godot 4.x project;
`--help` lists every check and the exit codes.
## Related skills
- `godot-gdscript` — language, lifecycle, and `@onready`.
- `godot-signals-groups` — decouple instanced scenes from their spawner.
- `godot-resources` — share data between instances without duplicating it.
- `save-systems` — persist scene/game state across runs.