Skip to content

L1.2 — Variables, Constants & the Type System

What you'll learn

  • Declare with var and const, and choose between explicit types, := inference, and untyped.
  • Use GDScript's core value types: int, float, bool, String, and StringName.
  • Build text with the % format operator and str(), and avoid the String + int error.
  • 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.5 in an int and it becomes 0 — 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 const defined once is one value forever; this is the single-source-of-truth discipline applied to numbers.
  • String building fails loudly in GDScript. "Score: " + score does not quietly stringify the number the way some languages do — it errors, because String + int has no meaning here. The % operator and str() 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.
  • booltrue or false.
  • 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:

const MAX_HEALTH := 100
const GRAVITY := 980.0

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:

var score := 1200
print("Score: " + score)     # ERROR: String + int is not defined

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:

print("%s has %d gold" % ["Player", 50])

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).

  1. Declare three variables in _ready: one inferred float, one explicit int, one untyped. Print all three.
  2. Assign a fractional literal (like 7.9) to your int variable, then print it. Observe the truncation — no error, value drops to 7.
  3. Try print("HP: " + your_int_var) and run. Read the error. Then fix it two ways — once with str(), once with % your_int_var and a %d specifier — and confirm both print correctly.
  4. Add a const at 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.