Skip to content

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, and push_warning to 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 assert at 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:

Invalid call. Nonexistent function 'take_damage' in base 'Nil'.

"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 at docs.godotengine.orgmatch 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.

  1. 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.
  2. Cause a runtime error: with a Node scene that has no child named Health, write @onready var h := $Health and, in _ready, h.queue_free(). Run, read the "call on Nil" message in the Output/Debugger, and identify from the message that h is null — then trace why (the missing child), and fix it by adding a child named Health.
  3. 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.
  4. 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.