Game Development with Godot & GDScript Module 10 – Static typing, debugging and testing
Errors, asserts and defensive code
What a GDScript runtime error stops and what carries on, how to check Error codes, when assert and push_error fit, and how to check input at the edges.
What you will learn
- Explain what a GDScript runtime error stops and what value the caller receives
- Check the Error codes of engine functions and turn them into messages with error_string()
- Choose between assert(), push_error(), push_warning() and a returned Error for a problem
- Validate data where it enters the game, so that a malformed file cannot crash it or half-apply
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what a GDScript runtime error stops and what value the caller receives
- Check the Error codes of engine functions and turn them into messages with error_string()
- Choose between assert(), push_error(), push_warning() and a returned Error for a problem
- Validate data where it enters the game, so that a malformed file cannot crash it or half-apply
Before you start
On this page
A damage function met a null target, Godot printed an error, and the fight went on with every hit dealing 0. GDScript errors are not exceptions: a runtime error ends the one function it happens in, and the caller carries on with a default value. So a game guards itself in three ways. It checks the Error codes that engine functions return. It states its own assumptions with asserts, which stop a function in debug runs. And it checks every file and value that comes from outside, which is called defensive code. Each is run below on Godot 4.7.2.
A runtime error stops one function, and the caller carries on
stops_one_function.gd has four small functions, and each meets a different runtime error: A mistake found while a line runs, such as calling a method on null or reading past the end of an array. GDScript stops the function it happens in, prints SCRIPT ERROR with a backtrace and lets the caller go on.: a method called on null, an array index past the end, an integer division by zero and a missing entry of
a PackedStringArray. _init() calls all four and prints what each returned:
extends SceneTree
func damage_to(target: Node) -> int:
print(" damage_to() starts")
var armour: int = target.get_meta("armour")
print(" damage_to() ends")
return 10 - armour
func third_wave(waves: Array[int]) -> int:
return waves[2]
func gold_each(gold: int, players: int) -> int:
@warning_ignore("integer_division")
return gold / players
func room_title(room: int) -> String:
var titles: PackedStringArray = ["Cellar", "Crypt"]
return titles[room]
func _init() -> void:
print("damage: ", damage_to(null))
print("third wave: ", third_wave([4, 6]))
print("gold each: ", gold_each(90, 0))
print("room title: '", room_title(3), "'")
print("the caller is still running")
quit() from session import files, run
# Four runtime errors in four functions, each called by _init(), then the Error codes of three engine functions that
# fail and one that works.
files("project.godot", "errors/stops_one_function.gd=stops_one_function.gd", "codes/codes.gd=codes.gd")
run("""
!godot --headless --no-header --script stops_one_function.gd
godot --headless --no-header --script codes.gd
""") Output
$ godot --headless --no-header --script stops_one_function.gd
damage_to() starts
SCRIPT ERROR: Cannot call method 'get_meta' on a null value.
at: damage_to (res://stops_one_function.gd:6)
GDScript backtrace (most recent call first):
[0] damage_to (res://stops_one_function.gd:6)
[1] _init (res://stops_one_function.gd:26)
damage: 0
SCRIPT ERROR: Out of bounds get index '2' (on base: 'Array[int]')
at: third_wave (res://stops_one_function.gd:12)
GDScript backtrace (most recent call first):
[0] third_wave (res://stops_one_function.gd:12)
[1] _init (res://stops_one_function.gd:27)
third wave: 0
SCRIPT ERROR: Division by zero error in operator '/'.
at: gold_each (res://stops_one_function.gd:17)
GDScript backtrace (most recent call first):
[0] gold_each (res://stops_one_function.gd:17)
[1] _init (res://stops_one_function.gd:28)
gold each: 0
SCRIPT ERROR: Out of bounds get index '3' (on base: 'PackedStringArray')
at: room_title (res://stops_one_function.gd:22)
GDScript backtrace (most recent call first):
[0] room_title (res://stops_one_function.gd:22)
[1] _init (res://stops_one_function.gd:29)
room title: ''
the caller is still running
$ godot --headless --no-header --script codes.gd
FileAccess.open(): file is null: true, error 7, File not found
ConfigFile.load(): error 7, File not found
JSON.parse(): error 43, Parse error: Expected value, got 'EOF'
DirAccess.make_dir_recursive_absolute(): error 0, OK
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 errors_session.py
Step through the first call, the one with the null target:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The call. _init() calls damage_to(null), with no target at all. damage_to() starts and prints its first line.
- The bad line. Line 6 calls get_meta() on target, which is null. There is no object to call it on, so the line cannot run.
- The error. Godot prints SCRIPT ERROR: Cannot call method get_meta on a null value, with a backtrace of two frames: damage_to() at line 6, called by _init() at line 26.
- The function stops. damage_to() stops at once and never prints its last line. It returns 0, the default value of its return type, int, as if 0 were the damage.
- The caller goes on. _init() prints damage: 0 and calls the next function. Three more errors went the same way, and the run ended with the caller is still running and exit status 0.
- With a debugger. With a debugger attached, the editor's or the one that -d starts, the same error also pauses the game on line 6, so you can look at target before anything goes on.
Every function stopped at its bad line and returned the default value of its return type: 0 for the three that return
an int, an empty String for room_title(). Nothing told the caller that the value was not real, so damage: 0 looked
like an answer. All four are runtime errors, the GDScript errors that show only while the game runs. The
backtrace: The list of function calls that led to an error or a warning, the most recent first, each with its file and line. Godot prints one under script errors and warnings since version 4.5. More in “Your first script and the game loop” under each one names the line and the caller, which is the fastest way to the
cause.
Checkpoint
Check: What a runtime error stops
Engine functions return an Error code instead of stopping
Many engine functions report failure with a value of the Error enum, an error code: A value of Godot's Error enum that many engine functions return instead of stopping: OK is 0, and every failure is a positive number, such as 7 for ERR_FILE_NOT_FOUND. error_string() turns a code into words., and
leave the decision to you. OK is 0 and every other code is a positive number, so if err != OK: is the usual test,
and error_string() turns a code into words. The second half of the session made four engine calls: two read files
that do not exist, one parses a piece of JSON that is cut off, and one makes a folder:
extends SceneTree
func _init() -> void:
var file := FileAccess.open("user://settings.json", FileAccess.READ)
var err := FileAccess.get_open_error()
print("FileAccess.open(): file is null: %s, error %d, %s" % [file == null, err, error_string(err)])
var config := ConfigFile.new()
err = config.load("user://controls.cfg")
print("ConfigFile.load(): error %d, %s" % [err, error_string(err)])
var json := JSON.new()
err = json.parse('{"volume": 0.8, "difficulty": ')
print("JSON.parse(): error %d, %s: %s" % [err, error_string(err), json.get_error_message()])
err = DirAccess.make_dir_recursive_absolute("user://saves/slot_1")
print("DirAccess.make_dir_recursive_absolute(): error %d, %s" % [err, error_string(err)])
if err != OK:
push_error("could not make the save folder: " + error_string(err))
quit() What each call returned, in the run above:
| Call | What came back |
|---|---|
FileAccess.open() |
null; FileAccess.get_open_error() gave 7, File not found |
ConfigFile.load() |
7, File not found |
JSON.parse() on cut-off text |
43, Parse error, with the message Expected value, got ‘EOF’ |
DirAccess.make_dir_recursive_absolute() |
0, OK: the folder was made |
None of these calls stopped the script. A function that ignores the returned code carries on as if the file had loaded, and the trouble shows up later, far from its cause.
Using what an engine call returned without checking it
var file := FileAccess.open(path, FileAccess.READ) followed at once by file.get_as_text() stops with a runtime
error when the file is missing, because file is null. Test if file == null: first and report
error_string(FileAccess.get_open_error()).
Checkpoint
Check: Error codes from engine functions
Report, refuse or return: four tools and where each belongs
GDScript gives four ways to deal with a problem you can foresee, and they differ in whether the function goes on:
| Tool | What it does |
|---|---|
assert(condition, message) |
checks an assumption of your own code; when it is false, an error and the function returns its default |
push_error(message) |
prints an error with a backtrace, and the function goes on |
push_warning(message) |
the same, as a warning |
return ERR_INVALID_DATA |
hands the problem to the caller, who decides what the game does |
The @GDScript reference calls assert() a stronger form of push_error(), and adds that the code inside it runs
only in debug builds and editor runs. Asserts are therefore for mistakes in your own code, never for a damaged file
from a player. Defensive code means checking data where it enters the game: a settings file, a save, a network message
or a value typed into the inspector. Inside the game, between your own typed functions, the types of the previous
lesson already check most of it.
Checkpoint
Check: assert, push_error, push_warning or an Error
assert, push_error and push_warning, each run to its end Pro: opens with a Pro, Premium or Ultimate pass
The three reporting tools look alike in a log and behave very differently in a running game. This part runs each one, reproduces the headless run that never ends after a failed assert, and shows what changes in a release export.
In this part:
- An assert stops its function, and an error report lets it go on
- An assert before quit() leaves a headless run waiting for ever
- In a release export, the code inside assert() never runs
3 runnable examples (GD, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A settings loader that checks every value before using it Pro: opens with a Pro, Premium or Ultimate pass
A settings file is written by the game and edited by players, so it is untrusted input every time it loads. This part builds a loader that refuses each kind of bad file with its own Error, and keeps the game’s settings untouched until the whole file has passed.
In this part:
- Seven files, six of them wrong, and what the loader returns for each
- Check everything first, then change anything
1 diagram · 3 runnable examples (GD, Python) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Every error in a log of your own, and a loader to build Pro: opens with a Pro, Premium or Ultimate pass
Errors that players meet are lost unless the game keeps them. This part collects every error and warning with a custom Logger, makes a check fail with the right exit status, and ends with a lab whose tempting solution reports OK on a broken file.
In this part:
- A custom Logger keeps each error with its script line
- Lab: a difficulty loader that never reports OK by mistake
7 runnable examples (GD, GODOT, Python) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise
These questions cover the first three sections, runtime errors, Error codes and the four tools; the weakest idea comes first and every answer explains itself:
Practice that adapts to you
Practise this lesson
9 questions about 6 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 happens when a GDScript function hits a runtime error?
GDScript has no exceptions, so nothing can be caught. The function stops at the line with the error, Godot prints SCRIPT ERROR with a backtrace of the calls that led there, and the function returns the default value of its return type: 0 for an int, an empty String, null for an object. The caller carries on with that value as if it were real, and the run’s exit status does not change. In the editor the debugger also pauses the game at that line. So code that must not go on with a default value checks its inputs before the line that can fail.
-
Fresher to mid
A settings loader declared as load_from(path) -> Error returns OK, yet the game starts with the wrong enemy count, and the log shows a SCRIPT ERROR from inside the loader. How can both be true, and how would you restructure the loader?
-
Mid to senior
In every playtest the crypt door opens with its key, but players of the released game say it never opens. The door calls assert(inventory.use_key("crypt")), and use_key() removes the key and marks the crypt as open. What is going on, and how do you fix it and find bugs like it?
-
Senior to staff
Your released game misbehaves on some players' machines, and you hear about it only through vague reviews. Design how the game records its errors and gets them back to you, and say what you would not collect.
Key takeaways
- A runtime error stops only the function it happens in; the function returns its type’s default, and the caller goes on with it.
- Engine functions such as
ConfigFile.load()return an Error code; test it againstOKand report it witherror_string(). assert()checks your own assumptions and stops its function in debug runs only,push_error()andpush_warning()report and go on, and a returned Error lets the caller decide.- Check data where it enters the game, files, saves, network messages and inspector values, and trust your typed code inside it.
Terms in this lesson
- error code
- A value of Godot's Error enum that many engine functions return instead of stopping: OK is 0, and every failure is a positive number, such as 7 for ERR_FILE_NOT_FOUND. error_string() turns a code into words.
- runtime error
- A mistake found while a line runs, such as calling a method on null or reading past the end of an array. GDScript stops the function it happens in, prints SCRIPT ERROR with a backtrace and lets the caller go on.
References
- @GlobalScope class reference: Error, error_string(), push_error() and push_warning() (Godot Engine)
- @GDScript class reference: assert() (Godot Engine)
- Logging: script backtraces and custom loggers (Godot Engine)
- Logger class reference (Godot Engine)
- Command line tutorial: --quit-after and --ignore-error-breaks (Godot Engine)
- Godot 4.5 release notes: script backtraces and custom loggers (Godot Engine)
- JSON class reference: parse() and get_error_message() (Godot Engine)
- ConfigFile class reference: load() and get_value() (Godot Engine)
Report a problem with this lesson
Kept only in this browser. Your Learn progress