L2.4 — Errors, Assertions & Documentation Literacy¶
What you'll learn
- Tell a parse error (before the game runs) from a runtime error (during play), and where each shows.
- Read an error message and its stack trace to find the cause, not just the line that crashed.
- Use
assert,push_error, andpush_warningto make invariants and problems loud. - Find an API in the Godot 4.6 class reference unaided — a skill, not a footnote.
How it applies
- Where an error appears tells you how expensive it was. A parse error costs a few seconds — it is caught before launch. A runtime error costs a play session, and a runtime error only on a rare path costs a QA pass or a player report. Every habit that moves an error left (typing, asserts) is buying down that cost.
- The crash line is rarely the cause. A "null instance" error names where a null was used, but the bug is wherever the reference should have been set. Reading the stack trace — the chain of calls that led here — is how you walk from symptom to cause instead of patching the symptom.
- An invariant left unchecked fails far away. A function that quietly accepts a bad argument
crashes three functions later, where the connection to the real mistake is lost. An
assertat the door fails at the door, naming the violated assumption. - Not finding the API is the real bottleneck. Most "how do I do X in Godot" questions are answered on one class-reference page. A developer who cannot navigate that reference is blocked by lookup, not by logic — which is why documentation literacy is a learning outcome of this book.
Concepts¶
Parse errors versus runtime errors¶
- A parse error is found when Godot compiles the script — before the game runs. The editor marks the line, and F6 refuses to launch until it is fixed. Type mismatches (L1.2), unknown identifiers, and bad syntax are parse errors. These are the cheap ones.
- A runtime error happens while the game is playing, when an actual value is wrong: a null reference, an out-of-range index (L1.5), a division setup that fails on certain data. It appears in the Output and Debugger docks, and execution stops at that line.
The whole thrust of static typing (Part A) is to convert would-be runtime errors into parse errors — to be told before you run rather than during.
Reading an error and its stack trace¶
A runtime error gives you a message, a file, and a line — and, crucially, a stack trace: the chain of function calls that reached the failing line. The most common message you will meet:
"base Nil" means you called a method on null — a reference that should have pointed at an object
but did not. The line it names is where you used the null; the cause is wherever that reference was
supposed to be assigned. The stack trace lets you walk back: which function called this one, and with
what, until you reach the place the value went wrong. Reading the trace, not just the last line, is the
difference between fixing the cause and moving the crash.
Example
A null-instance error traced to its real cause:
@onready var health := $Health # if there is no child named "Health", this is null
func _on_hit() -> void:
health.take_damage(5) # error reported HERE: call on Nil
The error points at health.take_damage(5), but the bug is upstream: $Health found no such
child (a rename, a typo, a missing node), so health is null. Fixing _on_hit is futile; the fix
is at the reference — correct the node name or add the missing child. The reported line is the
symptom; the trace and the reference declaration are the cause.
assert, push_error, push_warning¶
These make assumptions and problems visible:
assert(max_health > 0, "max_health must be positive") # halts (debug) if false
push_error("save file missing expected field") # logs an error, does not halt
push_warning("falling back to default loadout") # logs a warning, does not halt
assert checks an invariant and stops execution at the violation in debug builds, with your message —
it documents "this must be true here" and fails at the assumption rather than later. (Asserts are
stripped from release builds, so never put logic with side effects inside one.) push_error and
push_warning record a message to the Output/Debugger without stopping — for conditions you want
noticed and logged but can recover from. The QA framing: an assert is an inline test of an invariant
that runs every play session.
Documentation literacy¶
Finding an API is a skill the book expects you to build, because it is what makes you self-sufficient after the last chapter.
- The class reference. Every type has a page (e.g. the page for
Tween,Vector2,Node) listing its Properties, Methods, Signals, and its inheritance chain. Reading "what can this node do" means reading that page. The reference is bundled with the editor and online atdocs.godotengine.org— match the version to 4.6. - F1 in the script editor. Put the cursor on a symbol (a class, method, or property) and press F1 (or ++ctrl+click++) to jump straight to its documentation. This is the fastest path from "what does this do / what are its arguments" to the answer.
- Inspector hover tooltips. Hovering a property in the Inspector surfaces its docstring — the same text as the class reference, at the point of use.
Look it up
Open the Godot 4.6 class reference for Vector2 and find the method that returns the distance to
another vector, and the one that returns a length-1 copy. (You used both in L1.6 — confirm their
exact names and signatures from the reference, not from memory. Building the habit of verifying
against the docs is the point.)
Walkthrough¶
Use a fresh script on a Node scene.
- Cause a parse error: write
var n: int = "x". Note the editor flags it and F6 will not run. Read the message; then fix it. - Cause a runtime error: with a
Nodescene that has no child namedHealth, write@onready var h := $Healthand, in_ready,h.queue_free(). Run, read the "call on Nil" message in the Output/Debugger, and identify from the message thathis null — then trace why (the missing child), and fix it by adding a child namedHealth. - Add
assert(false, "reached the bad branch")inside a branch you expect never to run, force that branch, and observe the assert halt with your message. - Use F1: put your cursor on
queue_free(or any built-in method you used) and jump to its documentation. Read its description and note where it lives in the inheritance chain.
Optional sanity check
Replace your assert with push_warning("this branch is suspicious") and run the same path. The
message appears in the Output, but the game keeps running — the difference between halting on a
broken invariant (assert) and logging a non-fatal concern (push_warning).
Self-check quiz¶
Q1 — var n: int = \"x\" is reported before the game starts. What kind of error is this, and what does that imply about cost?
A. A runtime error; it is expensive because it only appears during play. B. A parse error; it is cheap because it is caught at compile time, before launch. C. A warning; it can be ignored. D. A stack overflow.
Reveal answer
B. A type mismatch is caught when the script is parsed, before the game runs, so F6 refuses to launch — the cheapest possible discovery point. A inverts parse versus runtime. C is wrong — it blocks the run, it is not an ignorable warning. D is unrelated.
Q2 — Invalid call. Nonexistent function 'take_damage' in base 'Nil'. What does base 'Nil' tell you?
A. The function take_damage does not exist anywhere.
B. You called take_damage on a value that is null; the reference was never set to a real object.
C. The game ran out of memory.
D. take_damage needs more arguments.
Reveal answer
B. base 'Nil' means the thing you called the method on is null, so there is no object to
find take_damage on. The named line is where the null was used; the cause is wherever the
reference should have been assigned (a missing child, a failed cast, an uninitialized variable).
A misreads it — the method likely exists on the intended type, which simply is not there. C and
D describe unrelated failures.
Q3 — What is the purpose of assert(max_health > 0, \"...\") over just hoping the value is valid?
A. It improves performance.
B. It documents and enforces an invariant, halting at the violation point with a message rather than letting a bad value crash somewhere unrelated later.
C. It exports max_health to the Inspector.
D. It runs in release builds to protect players.
Reveal answer
B. assert states "this must hold here" and fails immediately and loudly if it does not, so
the failure is at the assumption, not three functions downstream. A is unrelated. C confuses it
with @export. D is the opposite of the truth — asserts are stripped from release builds, so
they are a development/test instrument, not a runtime guard for players.
Integration question¶
Q4 — open
You press F6 and the game runs, then crashes with
Invalid call. Nonexistent function 'take_damage' in base 'Nil' at the line
enemy.take_damage(dmg). Walk the full diagnosis, drawing on Part A: what the message tells you,
what to check about enemy (referencing types, is/as, @onready timing), how the stack trace
helps, and which Part A habits would have caught it earlier.
Reveal expected answer
Read the message. base 'Nil' means enemy is null at this line, so the method cannot be
found — the bug is not in take_damage, it is that enemy points at nothing. Check how
enemy was obtained. If it came from $SomePath as a plain member, it was evaluated before
children existed and is null — it should be @onready (L2.2). If it came from a cast,
node as Enemy returns null when node is not an Enemy, and the missing null-guard (L2.1)
let the null through. If it came from a collection lookup, the key/index may have been absent
(L1.5). Use the stack trace. Walk back through the calling functions to see what assigned or
passed enemy; the trace shows the chain from the crash to the place the reference went wrong.
What would have caught it earlier: typing the variable (var enemy: Enemy) and guarding the
cast/null at the point enemy is set, so the problem surfaces where the reference is created
rather than where it is used; and an assert(enemy != null, "no enemy to hit") at the top of
the function, failing loudly at the assumption. The synthesis: the crash line is the symptom;
value-versus-reference semantics, safe casting, @onready timing, and typed/asserted contracts
— the spine of Part A — are how you reason from symptom to cause and how you stop the bug from
reaching runtime at all.