Game Development with Godot & GDScript Module 10 – Static typing, debugging and testing
Static typing and the warning system
Type GDScript variables, functions and collections, set warning levels in project.godot, make unsafe access safe and measure what typing speeds up.
What you will learn
- Type variables, parameters, return values, arrays and dictionaries, and predict what := infers
- Configure GDScript warning levels in project.godot and make a build fail on them
- Replace unsafe property and method access with typed variables and checked casts
- Measure which loops typing speeds up and which it leaves unchanged
Before you start
What you’ll learn · Before you start
What you will learn
- Type variables, parameters, return values, arrays and dictionaries, and predict what := infers
- Configure GDScript warning levels in project.godot and make a build fail on them
- Replace unsafe property and method access with typed variables and checked casts
- Measure which loops typing speeds up and which it leaves unchanged
Before you start
On this page
Godot 4.7.2 knows 49 warnings about GDScript code, and a headless run on a build machine prints none of the 37 that
only warn. A script that drops half a coin in a division, with a variable it never uses, passes --check-only with exit
status 0. Types written into the code, and warnings raised to errors, are how a project makes Godot refuse such lines
before the game starts.
This page types an enemy from top to bottom, sets the levels that decide what Godot only mentions and what it refuses, turns the unsafe lines of a script into checked ones, and measures what typing does to speed. Every run below is Godot 4.7.2.
Types written down let the parser check a line before it runs
enemy.gd gives everything it holds a static type: A type fixed for a variable when it is declared, as in var hp: int or var hp := 100. Godot then refuses any line that would store another type, with a parse error before the script runs. More in “Variables, types and operators”: hp: int written out, speed := 4.5
taken from its first value by type inference: Letting Godot take a variable's type from its first value, written :=. var speed := 4.5 makes speed a float for good, and var steps := 4 makes steps an int. More in “Variables, types and operators”, a typed array of patrol points, a typed
dictionary of loot, and a type for every parameter and return value. fight.gd uses it:
class_name Enemy
extends Node2D
var hp: int = 30
var speed := 4.5
var patrol: Array[Vector2] = [Vector2(0, 0), Vector2(64, 0), Vector2(64, 32)]
var loot: Dictionary[String, int] = {"coin": 3, "gem": 1}
func take_hit(damage: int) -> int:
hp = maxi(hp - damage, 0)
return hp
func route_length() -> float:
var total := 0.0
for i: int in range(1, patrol.size()):
total += patrol[i - 1].distance_to(patrol[i])
return total extends SceneTree
func _init() -> void:
var grunt := Enemy.new()
print("hp after a hit of 12: ", grunt.take_hit(12))
print("route length: ", grunt.route_length(), " px")
for item: String in grunt.loot:
print(item, " x", grunt.loot[item])
print("speed is a ", type_string(typeof(grunt.speed)))
grunt.speed = 3
print("after speed = 3: ", grunt.speed, ", still a ", type_string(typeof(grunt.speed)))
grunt.free()
quit() Three scripts in mistakes/ each break one promise. wrong_argument.gd passes damage := "12" to take_hit();
infer_from_dictionary.gd writes var hp := stats["hp"] where stats is a plain Dictionary;
infer_from_json.gd writes var data := JSON.parse_string(text). The session runs fight.gd, then parses the three
with --check-only, which reads a script and stops before running any of it, then runs declared.gd, which fixes both
inferences:
extends SceneTree
func _init() -> void:
var grunt := Enemy.new()
var damage := "12"
grunt.take_hit(damage)
grunt.free()
quit() extends SceneTree
func _init() -> void:
var stats: Dictionary[String, int] = {"hp": 30, "armour": 2}
var hp := stats["hp"]
var data: Variant = JSON.parse_string('{"hp": 30}')
print("hp is an ", type_string(typeof(hp)), ": ", hp)
print("data is a ", type_string(typeof(data)), ": ", data)
quit() from session import files, run
# A fresh copy of the project: one import registers class_name Enemy, then the typed scripts run and the three
# mistakes are parsed with --check-only.
files("project.godot", "typed/enemy.gd=enemy.gd", "typed/fight.gd=fight.gd", "typed/mistakes=mistakes")
run("""
godot --headless --quiet --import
godot --headless --no-header --script fight.gd
!godot --headless --no-header --check-only --script mistakes/wrong_argument.gd
!godot --headless --no-header --check-only --script mistakes/infer_from_dictionary.gd
!godot --headless --no-header --check-only --script mistakes/infer_from_json.gd
godot --headless --no-header --script mistakes/declared.gd
""") Output
$ godot --headless --quiet --import
$ godot --headless --no-header --script fight.gd
hp after a hit of 12: 18
route length: 96.0 px
coin x3
gem x1
speed is a float
after speed = 3: 3.0, still a float
$ godot --headless --no-header --check-only --script mistakes/wrong_argument.gd
SCRIPT ERROR: Parse Error: Invalid argument for "take_hit()" function: argument 1 should be "int" but is "String".
at: GDScript::reload (res://mistakes/wrong_argument.gd:7)
ERROR: Failed to load script "res://mistakes/wrong_argument.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script mistakes/infer_from_dictionary.gd
SCRIPT ERROR: Parse Error: Cannot infer the type of "hp" variable because the value doesn't have a set type.
at: GDScript::reload (res://mistakes/infer_from_dictionary.gd:6)
ERROR: Failed to load script "res://mistakes/infer_from_dictionary.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script mistakes/infer_from_json.gd
SCRIPT ERROR: Parse Error: The variable type is being inferred from a Variant value, so it will be typed as Variant. (Warning treated as error.)
at: GDScript::reload (res://mistakes/infer_from_json.gd:5)
ERROR: Failed to load script "res://mistakes/infer_from_json.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --script mistakes/declared.gd
hp is an int: 30
data is a Dictionary: { "hp": 30.0 }
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 typing_session.py
fight.gd printed 18 hp after a hit of 12, a route of 96 px, and a float for speed, because 4.5 is one; given
the int 3, speed held 3.0 and stayed a float. Each mistake was refused at its own line, before a single line ran.
What the parser knows from each kind of declaration:
| Declaration | What the parser knows | What it refuses |
|---|---|---|
var hp: int = 30 |
hp is an int for good | storing a String in it |
var speed := 4.5 |
inferred: a float for good | storing a String in it; an int becomes a float |
func take_hit(damage: int) -> int |
what goes in and what comes out | take_hit("12") |
Array[Vector2], Dictionary[String, int] |
the type of every element | an element of another type |
var hp := stats["hp"], stats a plain Dictionary |
nothing: the value has no set type | the line itself, Cannot infer the type |
var data := JSON.parse_string(text) |
a Variant: The type that can hold a value of any built-in type, an object or null. Untyped variables, parameters and array slots hold Variants, so the type of what they hold is known only when the line runs. More in “Arrays and typed arrays” | the line, through the warning inference_on_variant |
:= needs a value whose type the parser knows. A value from a plain dictionary has none, and a function declared
to return Variant, as JSON.parse_string() is, gives only Variant, which the default warning settings refuse to
infer. declared.gd shows both ways out: a typed dictionary, after which stats["hp"] is an int, or the type written
by hand, var data: Variant, which says on purpose that the value may be anything.
Version note
A typed loop variable, for item: String in loot, needs Godot 4.2 or later, as does the warning
untyped_declaration. Typed dictionaries, Dictionary[String, int], need Godot 4.4 or later.
Checkpoint
Check: Typed declarations and what := infers
Each warning has a level, and level 2 makes it an error
The parser also reports lines that are legal but suspicious, as warnings. coins.gd shares 7 coins between 2 players
with coins / 2 and declares a bonus it never uses. Every warning is a project setting under
debug/gdscript/warnings/, and its warning level: The setting that decides what Godot does with one kind of GDScript warning: 0 ignores it, 1 reports it in the editor and 2 makes it a parse error. Each warning has its own, under debug/gdscript/warnings/ in project.godot. decides what happens: 0 ignores it,
1 warns, 2 makes it an error. list_warnings.gd counts the warnings of Godot 4.7.2 by their default level; the session
then tries coins.gd five ways, the last two in strict/, whose project.godot raises both warnings to 2:
extends SceneTree
func _init() -> void:
var coins := 7
var bonus := 3
print("each of 2 players gets ", coins / 2, " coins")
quit() #!/bin/sh
# Parses one script and fails when Godot reports an error: --check-only itself exits with status 0 either way.
out=$(godot --headless --no-header --check-only --script "$1" 2>&1)
printf '%s\n' "$out"
if printf '%s\n' "$out" | grep -q "SCRIPT ERROR"; then
echo "check.sh: $1 has errors"
exit 1
fi
echo "check.sh: $1 is clean" from session import files, run
# The warnings Godot 4.7.2 knows and their default levels, then coins.gd (two warnings at their default level, Warn)
# run, parsed, and parsed with the local debugger on (-d), which prints warnings; then in strict/, whose project.godot
# raises both warnings to Error, parsed again, its exit status printed, and checked by check.sh.
files("project.godot", "warnings/list_warnings.gd=list_warnings.gd", "warnings/coins.gd=coins.gd",
"warnings/strict/project.godot=strict/project.godot", "warnings/coins.gd=strict/coins.gd",
"warnings/check.sh=strict/check.sh")
run("""
godot --headless --no-header --script list_warnings.gd
godot --headless --no-header --script coins.gd
godot --headless --no-header --check-only --script coins.gd
godot --headless --no-header -d --check-only --script coins.gd
cd strict
!godot --headless --no-header --check-only --script coins.gd; echo "exit status: $?"
!sh check.sh coins.gd
""") Output
$ godot --headless --no-header --script list_warnings.gd
49 warnings
Ignore by default: 8
Warn by default: 37
Error by default: 4
Ignore: untyped_declaration, inferred_declaration, unsafe_property_access, unsafe_method_access, unsafe_cast, unsafe_call_argument, return_value_discarded, missing_await
Error: inference_on_variant, native_method_override, get_node_default_without_onready, onready_with_export
$ godot --headless --no-header --script coins.gd
each of 2 players gets 3 coins
$ godot --headless --no-header --check-only --script coins.gd
$ godot --headless --no-header -d --check-only --script coins.gd
WARNING: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus".
at: GDScript::reload (res://coins.gd:6)
WARNING: Integer division. Decimal part will be discarded.
at: GDScript::reload (res://coins.gd:7)
$ cd strict
$ godot --headless --no-header --check-only --script coins.gd; echo "exit status: $?"
SCRIPT ERROR: Parse Error: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus". (Warning treated as error.)
at: GDScript::reload (res://coins.gd:6)
SCRIPT ERROR: Parse Error: Integer division. Decimal part will be discarded. (Warning treated as error.)
at: GDScript::reload (res://coins.gd:7)
ERROR: Failed to load script "res://coins.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
exit status: 0
$ sh check.sh coins.gd
SCRIPT ERROR: Parse Error: The local variable "bonus" is declared but never used in the block. If this is intended, prefix it with an underscore: "_bonus". (Warning treated as error.)
at: GDScript::reload (res://coins.gd:6)
SCRIPT ERROR: Parse Error: Integer division. Decimal part will be discarded. (Warning treated as error.)
at: GDScript::reload (res://coins.gd:7)
ERROR: Failed to load script "res://coins.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
check.sh: coins.gd has errors
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py
Step through what happened to the division at each level:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The line. coins.gd shares 7 coins between 2 players with coins / 2. Both values are ints, so the result is 3 and the half coin is dropped, which may or may not be what the game wants.
- The parser. Before anything runs, the parser reads the script and flags the division as integer_division, one of the 49 warnings Godot 4.7.2 knows. It flags bonus too, a variable declared and never used.
- The level. Each warning has a level: 0 Ignore, 1 Warn, 2 Error. Godot reads debug/gdscript/warnings/integer_division from project.godot, or takes the default, which is 1 for this warning.
- Level 0. At level 0 the warning is dropped, and nothing anywhere mentions it. Eight warnings start there, among them untyped_declaration and the unsafe_* warnings of member access, calls and casts.
- Level 1. At level 1 the script runs as written. The script editor lists the warning in its status bar, but a headless run and --check-only printed no warning; in a terminal only -d, the local debugger, printed it.
- Level 2. At level 2 the warning becomes a parse error that ends with (Warning treated as error.), and the script refuses to load. --check-only printed both errors and still ended with exit status 0.
Three facts of the run matter on a build machine. At level 1 the headless run and a plain --check-only printed
no warning at all: those warnings show in the editor’s status bar, and in a terminal only with -d, which starts the
local debugger and printed both. At level 2 the same file was refused, with (Warning treated as error.) after each
message. And --check-only still ended with exit status 0, so a pipeline must read what it printed, as check.sh
does by looking for SCRIPT ERROR.
Trusting the exit status of --check-only
godot --headless --check-only --script coins.gd && echo ok prints ok even when the script does not parse, because
the command ends with status 0 either way. Capture its output and fail when it contains SCRIPT ERROR, as check.sh
does: on the same file it ended with exit status 1.
The strict file sets each level under [debug], as gdscript/warnings/integer_division=2; the editor shows the same
settings in Project Settings, under Debug and GDScript, with Advanced Settings on. @warning_ignore("integer_division")
above a line silences that warning on that line, and @warning_ignore_start with @warning_ignore_restore does it for
a region. Scripts under res://addons get no warnings at all: the default directory_rules setting excludes that
folder, so third-party add-ons do not flood your list.
Key idea
A warning at level 1 is only a message in the editor; level 2 makes the parser refuse the script everywhere, and even
then --check-only exits with status 0, so a build has to read its output.
Checkpoint
Check: Warning levels and strict projects
Unsafe access is where the parser has to trust you
get_node() returns a Node, whatever the node really is. In arena.gd the grunt is reached as an untyped found, as
node: Node and through found as Enemy. All of it works while the game runs, yet the parser can check none of it:
Node has no take_hit() and no hp, so a typo in either name would surface only when the line runs. That is
unsafe access: Using a property or method through a type that does not declare it, such as take_hit() on a variable typed Node. The parser cannot check such a line, so a typo shows up only when it runs; the unsafe_* warnings report it.. Four unsafe_* warnings flag it, one each for property access, method
access, call arguments and casts, and like untyped_declaration they start at level 0, so nothing says a word by
default. arena_safe.gd casts the node once and uses the typed variable from then on. The session runs both in a
relaxed copy of the project, parses both in a strict copy that raises those five warnings to 2, and then runs
wrong_node.gd, where the node named Grunt is a plain Node2D:
extends SceneTree
func knock_back(target: Enemy) -> void:
target.position.x += 16
func _initialize() -> void:
var grunt := Enemy.new()
grunt.name = "Grunt"
root.add_child(grunt)
var found = root.get_node("Grunt")
var node: Node = root.get_node("Grunt")
node.take_hit(4)
print("hp: ", node.hp)
knock_back(found)
var cast := found as Enemy
print("x: ", cast.position.x)
quit() extends SceneTree
func knock_back(target: Enemy) -> void:
target.position.x += 16
func _initialize() -> void:
var grunt := Enemy.new()
grunt.name = "Grunt"
root.add_child(grunt)
var enemy := root.get_node("Grunt") as Enemy
if enemy == null:
push_error("Grunt is not an Enemy")
quit(1)
return
enemy.take_hit(4)
print("hp: ", enemy.hp)
knock_back(enemy)
print("x: ", enemy.position.x)
quit() extends SceneTree
## The node named Grunt is a plain Node2D this time. The cast with as gives null without a word; the typed
## assignment in typed_grunt() stops that function with an error, and typed_grunt() returns null.
func typed_grunt() -> Enemy:
var grunt: Enemy = root.get_node("Grunt")
return grunt
func _initialize() -> void:
var stand_in := Node2D.new()
stand_in.name = "Grunt"
root.add_child(stand_in)
var cast := root.get_node("Grunt") as Enemy
print("as Enemy gave: ", cast)
var typed := typed_grunt()
print("typed_grunt() gave: ", typed)
quit() from session import files, run
# Two copies of one project: relaxed/ with the default warnings, strict/ whose project.godot raises the four unsafe_*
# warnings of member access, calls and casts, and untyped_declaration, to Error. arena.gd reaches the Grunt through
# untyped and Node-typed variables; arena_safe.gd casts it to Enemy once. Both run in relaxed/, then strict/ parses
# both. Last, wrong_node.gd runs in relaxed/ with a plain Node2D named Grunt: as gives null, a typed assignment stops.
for folder, settings in (("relaxed", "project.godot"), ("strict", "unsafe/strict/project.godot")):
files(f"{settings}={folder}/project.godot", f"typed/enemy.gd={folder}/enemy.gd",
f"unsafe/arena.gd={folder}/arena.gd", f"unsafe/arena_safe.gd={folder}/arena_safe.gd")
files("unsafe/wrong_node.gd=relaxed/wrong_node.gd")
run("""
cd relaxed
godot --headless --quiet --import
godot --headless --no-header --script arena.gd
godot --headless --no-header --script arena_safe.gd
cd ../strict
godot --headless --quiet --import
!godot --headless --no-header --check-only --script arena.gd
godot --headless --no-header --check-only --script arena_safe.gd
cd ../relaxed
!godot --headless --no-header --script wrong_node.gd
""") Output
$ cd relaxed
$ godot --headless --quiet --import
$ godot --headless --no-header --script arena.gd
hp: 26
x: 16.0
$ godot --headless --no-header --script arena_safe.gd
hp: 26
x: 16.0
$ cd ../strict
$ godot --headless --quiet --import
$ godot --headless --no-header --check-only --script arena.gd
SCRIPT ERROR: Parse Error: Variable "found" has no static type. (Warning treated as error.)
at: GDScript::reload (res://arena.gd:12)
SCRIPT ERROR: Parse Error: The method "take_hit()" is not present on the inferred type "Node" (but may be present on a subtype). (Warning treated as error.)
at: GDScript::reload (res://arena.gd:14)
SCRIPT ERROR: Parse Error: The property "hp" is not present on the inferred type "Node" (but may be present on a subtype). (Warning treated as error.)
at: GDScript::reload (res://arena.gd:15)
SCRIPT ERROR: Parse Error: The argument 1 of the function "knock_back()" requires the subtype "Enemy" but the supertype "Variant" was provided. (Warning treated as error.)
at: GDScript::reload (res://arena.gd:16)
SCRIPT ERROR: Parse Error: Casting "Variant" to "Enemy" is unsafe. (Warning treated as error.)
at: GDScript::reload (res://arena.gd:17)
ERROR: Failed to load script "res://arena.gd" with error "Parse error".
at: load (modules/gdscript/gdscript_resource_format.cpp:46)
$ godot --headless --no-header --check-only --script arena_safe.gd
$ cd ../relaxed
$ godot --headless --no-header --script wrong_node.gd
as Enemy gave: <null>
SCRIPT ERROR: Trying to assign value of type 'Node2D' to a variable of type 'enemy.gd'.
at: typed_grunt (res://wrong_node.gd:7)
GDScript backtrace (most recent call first):
[0] typed_grunt (res://wrong_node.gd:7)
[1] _initialize (res://wrong_node.gd:17)
typed_grunt() gave: <null>
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 unsafe_session.py
Both scripts printed hp 26 and x 16.0, so their behaviour is the same. The strict copy refused five lines of
arena.gd and none of arena_safe.gd:
| Line of arena.gd | Warning at level 2 | The safe form |
|---|---|---|
var found = root.get_node("Grunt") |
untyped_declaration | var enemy := root.get_node("Grunt") as Enemy |
node.take_hit(4) |
unsafe_method_access | enemy.take_hit(4) |
print("hp: ", node.hp) |
unsafe_property_access | enemy.hp |
knock_back(found) |
unsafe_call_argument | knock_back(enemy) |
var cast := found as Enemy |
unsafe_cast | cast the Node, which has a type, not a Variant |
wrong_node.gd met a node of the wrong type both ways. The cast with as gave <null> without a word, as the
static typing page warns, which is why arena_safe.gd tests the result and reports the mismatch with push_error().
The typed assignment, var grunt: Enemy = root.get_node("Grunt"), stopped on its own line with Trying to assign value
of type ‘Node2D’ to a variable of type ‘enemy.gd’, which is why the page calls that form the more reliable one for
@onready variables: the mistake shows where it is made.
The global functions follow the same pattern: the static typing page lists abs(), clamp(), round() and five more
as not statically typed, and their typed counterparts, such as absf(), clampi() and roundi(), keep a line safe.
Checkpoint
Check: Unsafe access and checked casts
Typing speeds up arithmetic, and not every line
The static typing page says typed code uses optimised opcodes, the engine’s instructions for an operation, when the
types are known as the script is compiled. typed_bench.gd puts numbers on that: five loops of 200,000 passes, each
written typed and untyped, run 41 times in turn, keeping the fastest run of each so that a busy moment of the computer
spoils one run, not the result:
extends SceneTree
## Times five loops of 200,000 passes, typed and untyped. Each pair runs 41 times, typed and untyped in turn, and
## the fastest run of each is printed, so that a busy moment of the computer spoils one run rather than the result.
const N := 200_000
const RUNS := 41
class Mover:
var speed: float = 0.0
func float_typed() -> void:
var x: float = 1.0
for i: int in N:
x = x * 1.000001 + 0.5
func float_untyped():
var x = 1.0
for i in N:
x = x * 1.000001 + 0.5
func int_typed() -> void:
var total: int = 0
for i: int in N:
total += i
func int_untyped():
var total = 0
for i in N:
total += i
func vector_typed() -> void:
var v: Vector2 = Vector2.ZERO
var step: Vector2 = Vector2(0.5, 0.25)
for i: int in N:
v += step * 0.5
func vector_untyped():
var v = Vector2.ZERO
var step = Vector2(0.5, 0.25)
for i in N:
v += step * 0.5
func node_typed(node: Node2D) -> void:
for i: int in N:
node.rotation = 0.5
func node_untyped(node):
for i in N:
node.rotation = 0.5
func member_typed(mover: Mover) -> void:
for i: int in N:
mover.speed = 0.5
func member_untyped(mover):
for i in N:
mover.speed = 0.5
func fastest_ms(job: Callable, best: float) -> float:
var start := Time.get_ticks_usec()
job.call()
return minf(best, (Time.get_ticks_usec() - start) / 1000.0)
func _init() -> void:
var node := Node2D.new()
var mover := Mover.new()
var cases: Array[Array] = [
["float maths", float_typed, float_untyped],
["int sum", int_typed, int_untyped],
["Vector2 maths", vector_typed, vector_untyped],
["Node2D.rotation writes", node_typed.bind(node), node_untyped.bind(node)],
["script member writes", member_typed.bind(mover), member_untyped.bind(mover)],
]
print("Godot %s, 200,000 passes, fastest of %d runs" % [Engine.get_version_info().string, RUNS])
for row: Array in cases:
var typed_ms := INF
var untyped_ms := INF
for r: int in RUNS:
typed_ms = fastest_ms(row[1], typed_ms)
untyped_ms = fastest_ms(row[2], untyped_ms)
print("%-23s typed %5.2f ms untyped %5.2f ms untyped/typed %.2f" % [row[0], typed_ms, untyped_ms, untyped_ms / typed_ms])
node.free()
quit() from session import files, run
# typed_bench.gd times typed and untyped loops; the times depend on the computer and change from run to run.
files("project.godot", "bench/typed_bench.gd=typed_bench.gd")
run("""
godot --headless --no-header --script typed_bench.gd
""") Output
$ godot --headless --no-header --script typed_bench.gd Godot 4.7.2-stable (official), 200,000 passes, fastest of 41 runs float maths typed 4.03 ms untyped 5.56 ms untyped/typed 1.38 int sum typed 2.92 ms untyped 3.95 ms untyped/typed 1.35 Vector2 maths typed 4.16 ms untyped 6.30 ms untyped/typed 1.52 Node2D.rotation writes typed 11.87 ms untyped 11.99 ms untyped/typed 1.01 script member writes typed 6.55 ms untyped 6.50 ms untyped/typed 0.99
This output changes from run to run: the times depend on the computer and change from run to run
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bench_session.py
On the computer that recorded this run, typed float, int and Vector2 maths ran 1.35 to 1.5 times as fast as the same
loops untyped. Writing a node’s rotation and a script’s member variable took the same time typed or untyped, within
1 %: the gain came from the arithmetic the loop does itself, and none from the writes into another object. So type
everything for the checks of the first three sections, and when a frame is slow, measure the loop that costs the time
before rewriting it for speed; the debugger and profiler lesson of this module shows how.
Checkpoint
Check: What typing speeds up
Practise: type a slime for a strict project
slime.gd works, and nothing in it is typed. Type it so that it passes --check-only in a project that raises
untyped_declaration and those four unsafe_* warnings to 2, and so that it behaves exactly as before.
- Make a folder with the files below: the task’s
project.godot,slime.gdand the checkercheck_slime.gd. - Type every variable, parameter, return value and signal argument, and give
targetsanddropselement types. - In a terminal there, run
godot --headless --no-header --check-only --script slime.gduntil it prints nothing, thengodot --headless --no-header --script check_slime.gd.
config_version=5
[application]
config/name="Strict slime"
[debug]
gdscript/warnings/untyped_declaration=2
gdscript/warnings/unsafe_property_access=2
gdscript/warnings/unsafe_method_access=2
gdscript/warnings/unsafe_cast=2
gdscript/warnings/unsafe_call_argument=2 extends Node2D
## A slime that splits in two when it dies. Type every variable, parameter and return value, and the two
## collections, so that the script passes --check-only in this strict project and behaves as before.
signal split(count)
var hp = 40.0
var regen_per_second = 0.5
var targets = []
var drops = {"gel": 2, "seed": 1}
## Takes damage and returns the hit points left; at 0 or below, emits split once with 2.
func take_hit(amount):
if hp <= 0:
return hp
hp -= amount
if hp <= 0:
split.emit(2)
return hp
## Regains regen_per_second for each second, never above 40.
func regenerate(seconds):
hp = min(hp + regen_per_second * seconds, 40.0)
## The point of targets closest to the slime, or Vector2.INF when there is none.
func nearest():
var best = Vector2.INF
var best_distance = INF
for point in targets:
var distance = position.distance_to(point)
if distance < best_distance:
best_distance = distance
best = point
return best
## How many items the slime drops in all.
func loot_total():
var total = 0
for item in drops:
total += drops[item]
return total extends SceneTree
## Checks res://slime.gd: its behaviour, then that every declaration in it has a static type. It runs in the same
## strict project, so it is fully typed too: values that come back as Variant go into typed variables first.
var passed := 0
var failed := 0
func check(ok: bool, what: String) -> void:
if ok:
passed += 1
print("PASS ", what)
else:
failed += 1
print("FAIL ", what)
func make() -> Node2D:
var slime_script: GDScript = load("res://slime.gd")
return slime_script.new()
func _init() -> void:
var slime := make()
var left: float = slime.call("take_hit", 15.5)
check(is_equal_approx(left, 24.5), "take_hit(15.5) leaves 24.5 hp (got %s)" % left)
slime.free()
slime = make()
var splits: Array[int] = []
slime.connect("split", func(count: int) -> void: splits.append(count))
for i: int in 3:
slime.call("take_hit", 30.0)
check(splits == [2], "three hits of 30 emit split(2) once (got %s)" % [splits])
slime.free()
slime = make()
slime.set("hp", 39.0)
slime.call("regenerate", 1.0)
var hp: float = slime.get("hp")
check(is_equal_approx(hp, 39.5), "regenerate(1.0) from 39 gives 39.5 hp (got %s)" % hp)
slime.call("regenerate", 10.0)
hp = slime.get("hp")
check(is_equal_approx(hp, 40.0), "regenerate(10.0) stops at 40 hp (got %s)" % hp)
slime.free()
slime = make()
var near: Vector2 = slime.call("nearest")
check(near == Vector2.INF, "nearest() with no targets is Vector2.INF")
var targets: Array[Vector2] = [Vector2(30, 40), Vector2(6, 8)]
slime.set("targets", targets)
near = slime.call("nearest")
check(near == Vector2(6, 8), "nearest() picks (6, 8) from the origin (got %s)" % near)
var loot: int = slime.call("loot_total")
check(loot == 3, "loot_total() is 3 (got %d)" % loot)
slime.free()
var slime_script: Script = load("res://slime.gd")
check_types(slime_script)
print("%d of %d checks passed" % [passed, passed + failed])
quit(0 if failed == 0 else 1)
func check_types(slime_script: Script) -> void:
var untyped := PackedStringArray()
var element_types := PackedStringArray()
for property: Dictionary in slime_script.get_script_property_list():
var name: String = property.name
var usage: int = property.usage
var hint: String = property.hint_string
if usage & PROPERTY_USAGE_SCRIPT_VARIABLE and usage & PROPERTY_USAGE_NIL_IS_VARIANT:
untyped.append("var " + name)
if name in ["targets", "drops"] and hint != "":
element_types.append(name)
for method: Dictionary in slime_script.get_script_method_list():
var method_name: String = method.name
var result: Dictionary = method.return
var result_usage: int = result.usage
if result_usage & PROPERTY_USAGE_NIL_IS_VARIANT:
untyped.append(method_name + "() return")
for arg: Dictionary in method.args:
var arg_usage: int = arg.usage
if arg_usage & PROPERTY_USAGE_NIL_IS_VARIANT:
untyped.append("%s(%s)" % [method_name, arg.name])
for sig: Dictionary in slime_script.get_script_signal_list():
for arg: Dictionary in sig.args:
var arg_type: int = arg.type
if arg_type == TYPE_NIL:
untyped.append("signal %s(%s)" % [sig.name, arg.name])
check(untyped.is_empty(), "every declaration has a static type (untyped: %s)" % ", ".join(untyped))
check(element_types.size() == 2, "targets and drops have element types (typed: %s)" % ", ".join(element_types)) The checker tests the slime’s behaviour, then reads the script’s own list of members, functions and signals to find anything left untyped. On the starter, on a finished slime and on a tempting one whose hit points are an int:
from session import files, run
# The task's strict project three times: with the untyped starter, with a finished slime.gd and with a tempting one
# whose hit points are an int. In each, --check-only parses slime.gd, then check_slime.gd tests it.
for folder, slime in (("starter", "practice/slime.gd"), ("finished", "practice/solution/slime.gd"), ("tempting", "practice/tempting/slime.gd")):
files(f"practice/project.godot={folder}/project.godot", f"practice/check_slime.gd={folder}/check_slime.gd",
f"{slime}={folder}/slime.gd")
run("""
cd starter
!godot --headless --no-header --check-only --script slime.gd 2>&1 | grep "SCRIPT ERROR" | head -n 3
godot --headless --no-header --check-only --script slime.gd 2>&1 | grep -c "SCRIPT ERROR"
cd ../finished
godot --headless --no-header --check-only --script slime.gd
godot --headless --no-header --script check_slime.gd
cd ../tempting
godot --headless --no-header --check-only --script slime.gd
!godot --headless --no-header --script check_slime.gd
""") Output
$ cd starter $ godot --headless --no-header --check-only --script slime.gd 2>&1 | grep "SCRIPT ERROR" | head -n 3 SCRIPT ERROR: Parse Error: Parameter "count" has no static type. (Warning treated as error.) SCRIPT ERROR: Parse Error: Variable "hp" has no static type. (Warning treated as error.) SCRIPT ERROR: Parse Error: Variable "regen_per_second" has no static type. (Warning treated as error.) $ godot --headless --no-header --check-only --script slime.gd 2>&1 | grep -c "SCRIPT ERROR" 18 $ cd ../finished $ godot --headless --no-header --check-only --script slime.gd $ godot --headless --no-header --script check_slime.gd PASS take_hit(15.5) leaves 24.5 hp (got 24.5) PASS three hits of 30 emit split(2) once (got [2]) PASS regenerate(1.0) from 39 gives 39.5 hp (got 39.5) PASS regenerate(10.0) stops at 40 hp (got 40.0) PASS nearest() with no targets is Vector2.INF PASS nearest() picks (6, 8) from the origin (got (6.0, 8.0)) PASS loot_total() is 3 (got 3) PASS every declaration has a static type (untyped: ) PASS targets and drops have element types (typed: targets, drops) 9 of 9 checks passed $ cd ../tempting $ godot --headless --no-header --check-only --script slime.gd $ godot --headless --no-header --script check_slime.gd FAIL take_hit(15.5) leaves 24.5 hp (got 25.0) PASS three hits of 30 emit split(2) once (got [2]) FAIL regenerate(1.0) from 39 gives 39.5 hp (got 39.0) PASS regenerate(10.0) stops at 40 hp (got 40.0) PASS nearest() with no targets is Vector2.INF PASS nearest() picks (6, 8) from the origin (got (6.0, 8.0)) PASS loot_total() is 3 (got 3) PASS every declaration has a static type (untyped: ) PASS targets and drops have element types (typed: targets, drops) 7 of 9 checks passed [exit status 1]
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 starter broke 18 rules. The tempting slime passed --check-only, because every declaration had a type, and still
failed two checks: an int cannot hold 24.5 hp, and half a hit point of regeneration was cut to 0. The strict settings
catch missing types, never a wrong one; behaviour tests catch that. When your slime prints 9 of 9 checks passed, try
the questions below: they cover the four sections above, beginning with whichever your answers show you are least sure
of.
Practice that adapts to you
Practise this lesson
12 questions about 4 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 is the difference between var hp = 30, var hp: int = 30 and var hp := 30?
var hp = 30 declares an untyped variable: it holds a Variant, so a later hp = "full" is accepted and errors appear
only when a line uses the value. var hp: int = 30 gives hp the static type int for good, so the parser refuses any
line that would store something else, such as a String. var hp := 30 infers the same type, int, from the first
value. := fails when the value has no type the parser knows: a value of a plain dictionary gives the parse error
Cannot infer the type, and a function that returns Variant, such as JSON.parse_string(), is refused by the warning
inference_on_variant, which is at level 2 by default. Then write the type, or type the collection.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Your build machine runs godot --headless --check-only on every changed script and has never failed, yet a script that does not parse reached the main branch. What went wrong, and how do you change the job so that it catches this and the warnings your team cares about?
-
Mid to staff
You join a game with 300 untyped scripts, and playtests keep stopping on errors such as a method missing on a Node. How would you move the project to strict typing without stopping feature work?
Key takeaways
- Types on variables, parameters, return values and collections let the parser refuse a wrong line before the game
runs;
:=infers a type only from a value whose type the parser knows. - Every GDScript warning has a level in
project.godot, 0 Ignore, 1 Warn or 2 Error; level 1 shows only in the editor, or in a terminal with-d, and level 2 makes the warning a parse error. --check-onlyends with exit status 0 even when a script does not parse, so a build must read its output for SCRIPT ERROR.- Unsafe access, a member used through a type that does not declare it, is flagged by the
unsafe_*warnings once they are raised, and a checked cast withas, or a typed variable, makes the line safe. - Typing sped up float, int and Vector2 arithmetic about 1.35 to 1.5 times and left property writes unchanged, so type everything for the checks and measure before optimising for speed.
Terms in this lesson
- unsafe access
- Using a property or method through a type that does not declare it, such as take_hit() on a variable typed Node. The parser cannot check such a line, so a typo shows up only when it runs; the unsafe_* warnings report it.
- warning level
- The setting that decides what Godot does with one kind of GDScript warning: 0 ignores it, 1 reports it in the editor and 2 makes it a parse error. Each warning has its own, under debug/gdscript/warnings/ in project.godot.
References
- Static typing in GDScript (Godot Engine)
- GDScript warning system (Godot Engine)
- ProjectSettings class reference: debug/gdscript/warnings (Godot Engine)
- Command line tutorial: --check-only and -d (Godot Engine)
- Godot 4.2 release notes: typed for loops and untyped_declaration (Godot Engine)
- Godot 4.4 release notes: typed dictionaries (Godot Engine)
Report a problem with this lesson
Kept only in this browser. Your Learn progress