L1.2 — Variables, Constants & the Type System¶
What you'll learn
- Declare with
varandconst, and choose between explicit types,:=inference, and untyped. - Use GDScript's core value types:
int,float,bool,String, andStringName. - Build text with the
%format operator andstr(), and avoid theString + interror. - Treat a type annotation as a contract the compiler checks before the game ever runs.
How it applies
- Untyped variables defer errors to the player. A variable with no type can hold anything, so a wrong-type assignment surfaces only when that line runs — possibly in the field, in a code path QA did not hit. A type annotation converts that into a parse-time error you cannot miss. This is the cheapest defect-shifting move in the language.
- Silent truncation is a data-loss bug. Store
0.5in anintand it becomes0— no error, just a wrong number that propagates. Knowing which type a value needs prevents a whole family of "the math is slightly off" reports. - Magic numbers drift. The same tuning value copied into three scripts becomes three values the
day someone edits two of them. A
constdefined once is one value forever; this is the single-source-of-truth discipline applied to numbers. - String building fails loudly in GDScript.
"Score: " + scoredoes not quietly stringify the number the way some languages do — it errors, becauseString + inthas no meaning here. The%operator andstr()are the supported paths, and knowing that up front avoids a recurring stumble.
Concepts¶
var, and three ways to type it¶
var declares a variable. You can type it three ways:
var a = 5 # untyped (dynamic): a can later hold anything
var b: int = 5 # explicit: b is an int, checked by the compiler
var c := 5 # inferred: type taken from the value (int), still static
b and c are both statically typed — the compiler knows they are int and will reject
b = "five" before the game runs. a is dynamic: legal to reassign to any type, and any mistake
waits until runtime to bite. The idiom in this book, and in the game books, is := inference when the
value makes the type obvious, and an explicit annotation when it does not (an empty array, a value
that arrives later, a parameter). You already know what a variable is; the new habit is typing it.
Example
Inference reads the right-hand side; annotation states intent for cases inference cannot see:
var speed := 150.0 # inferred float — obvious from 150.0
var target: Node2D # annotated — no initial value to infer from
target has no initializer, so := could not work; the annotation tells the compiler (and the
reader) what will go there. An annotated variable with no value gets a typed default, not null:
a bare var n: int starts at 0, var f: float at 0.0, var b: bool at false.
The core value types¶
Five types cover most of Part A:
int— a 64-bit signed integer. Whole numbers, counts, indices.float— a 64-bit floating-point number (a double, despite the name). All non-integer math.bool—trueorfalse.String— mutable text, the general-purpose string.StringName— an interned string: stored once, compared by identity, so equality checks are near-instant. Used for fixed identifiers — node names, animation names, the argument to a signal. Written with an&prefix:&"idle".
You will not choose StringName often in Part A, but you will see it: the game books emit signals
like transitioned.emit(&"Attack"), and the &"Attack" is a StringName chosen because the engine
compares it many times per second. Treat &"x" as "a String optimized for being matched against,"
and move on.
Example
int and float are different types, and mixing them has consequences you meet in L1.3
(integer division). For now, note one quiet conversion:
var hp: int = 10
hp = 7.9 # stored as 7 — the float is truncated to fit the int (a warning, not an error)
The assignment succeeds and drops the fraction: GDScript flags the narrowing with a warning (not
an error), so the game still runs and hp becomes 7 — the type system is telling you precision is
being lost, not stopping you. If a value can be fractional, type it
float. If it must be whole (a count, an index), type it int and do the rounding deliberately
(roundi, floori, ceili) rather than letting a truncation decide for you.
const — a name fixed at compile time¶
const declares a value that cannot change and is known at compile time:
The convention is SCREAMING_SNAKE_CASE. A const must be initialized from a constant expression
(literals, math on other consts) — you cannot const x := some_function(). Its payoff is
single-source-of-truth: the tuning number lives in one place, and every use reads the same value. The
bug it prevents is drift — three copies of 1.15 that stop agreeing the day two of them get edited.
Building text: % and str()¶
GDScript does not auto-stringify across +. This errors:
Two supported fixes:
print("Score: " + str(score)) # str() converts to String first
print("Score: %d" % score) # % format operator: %d = integer
The % operator fills format specifiers with values: %d integer, %s any value as text, %f
float (%0.2f for two decimals). For more than one value, supply an array:
The game books use %d/%s for HUD text; str() is the quick path when you just need something
turned into a String.
Walkthrough¶
Reuse the scene and script from L1.1 (a Node with a script, code in _ready).
- Declare three variables in
_ready: one inferredfloat, one explicitint, one untyped. Print all three. - Assign a fractional literal (like
7.9) to yourintvariable, then print it. Observe the truncation — no error, value drops to7. - Try
print("HP: " + your_int_var)and run. Read the error. Then fix it two ways — once withstr(), once with% your_int_varand a%dspecifier — and confirm both print correctly. - Add a
constat the top of the script (outside_ready, e.g.const MAX := 100) and print it. Then try to reassign it inside_ready(MAX = 50) and read the error that constants cannot be reassigned. Remove the bad line.
Optional sanity check
Change your explicitly-typed int variable's assignment to a String (var b: int = "x") and
run. The error appears before the game window opens — that is the parse-time contract doing its
job. Change it back. Then do the same with the untyped variable: the editor stays quiet, because
a dynamic variable has no contract to violate.
Self-check quiz¶
Q1 — Which declaration is statically typed as a float without writing the type name?
A. var x = 150.0
B. var x := 150.0
C. var x: float
D. var x = float(150)
Reveal answer
B. := infers the static type from the value 150.0, which is a float. A is untyped
(dynamic) — the value is a float but the variable has no type contract. C is statically typed
but does write the type name (and has no value). D is dynamic again (=, not :=), regardless
of the float() call on the right.
Q2 — var hp: int = 10 then hp = 7.9. What is hp, and was there an error?
A. 7.9, no error — the type widens to float.
B. 7, no error — the float is truncated to fit the int.
C. Error — you cannot assign a float to an int.
D. 8, no error — it rounds to nearest.
Reveal answer
B. Assigning a float to an int variable truncates toward zero and stores 7, silently.
A is wrong because a typed variable does not change type. C is wrong because the assignment is
permitted (it truncates rather than erroring). D is wrong because the conversion truncates, it
does not round — if you want rounding, call roundi yourself.
Q3 — var name := \"Vex\" and var n := 3. Which line prints Vex x3 without error?
A. print(name + " x" + n)
B. print("%s x%d" % [name, n])
C. print(name " x" n)
D. print(name + " x" + n.string)
Reveal answer
B. The % operator fills %s with name and %d with n, taking an array for the two
values. A errors at + n because String + int is undefined — the most common version of this
mistake. C is not valid syntax (no operator between the pieces). D invents an .string
property that does not exist; the conversion function is str(n).
Integration question¶
Q4 — open
A teammate's script declares var damage = 10 (untyped) and, fifty lines later, accidentally does
damage = "10" (a string, from some text field). The game ships; players report that a certain
attack does no damage. Explain how typing the variable var damage: int = 10 would have changed
when this defect was discovered, and connect that to why this chapter frames a type annotation as
a "contract checked before the game runs."
Reveal expected answer
Untyped, damage = "10" is a legal assignment; the variable simply now holds a String. The
defect only manifests later, wherever damage is used in arithmetic — at runtime, in whatever
code path triggers that attack, possibly only in the shipped build. Typed as int, the
assignment damage = "10" is rejected by the compiler at parse time, before the game can
run, with a clear error at the offending line. The annotation is a contract: it constrains what
the variable may hold and lets the compiler enforce it up front, converting a field-discovered
logic bug into an editor-time error. That shift — from "found by a player" to "found before
launch" — is the entire value of static typing, and why the book types everything.