Hi everyone ✋
In the previous post we learned that every GDScript file is a class, we attached a script to a node, and we got a label counting up on screen using _ready() and _process(delta).
Today we do variables and data types.
I know how that sounds 😴 We have all declared a few million variables by now. But this is the post where GDScript stops being predictable, because it makes a genuinely strange choice ─ static typing is optional. Not absent. Optional. And that one decision has consequences all the way down.
Here is what we are covering ─
- The three ways to declare a variable, and why
:=is not what Python people think it is. Variant─ the type that is secretly every type.- Why we should type everything anyway, and how to make Godot enforce it.
- The built-in engine types like
Vector2andColor, which quietly delete a lot of code we are used to writing.
And we will finish with something bouncing around the screen 🥳
Let’s take a deep dive 🤿
Three ways to declare a variable
All three of these are valid GDScript ─
var health = 100 var speed: float = 250.0 var player_name := "Rook"
They look like small stylistic variations. They are not ─ the first one is fundamentally different from the other two, and knowing which is which matters.
var health = 100─ untyped. This variable has no declared type at all.var speed: float = 250.0─ explicitly typed. We named the type ourselves.var player_name := "Rook"─ inferred type. Godot looks at the value, works out that it is aString, and locks that in.
That third form is the one to notice. The colon-equals is a single operator, and it means “figure out the type for me and then hold me to it.” Once inferred, the type is fixed ─ assigning a Vector2 to player_name later is an error, exactly as if we had written : String by hand.
Coming-from note 🤔 ─
:=is Go’s short declaration, or C++’sauto, or Kotlin’sval, or Swift’sletwith the type left off. What it is not is Python’s walrus operator. In Python,:=is an assignment expression used inside conditions. In GDScript it is a type-inferring declaration. Same two characters, completely unrelated jobs 😱
Hold on ─ typing is optional? 🤔
Yes. var health = 100 compiles and runs perfectly happily, with no type anywhere.
So what type does it actually have?
It holds a Variant, which is Godot’s universal container ─ a value that can hold anything the engine knows about. And “anything” is not an exaggeration ─
var thing = 100 thing = "now I am a string" thing = Vector2(3, 4) thing = [1, 2, 3] thing = null
Every one of those lines is legal. No error, no warning by default, no complaint. The variable simply changes what it is holding.
Coming from C++ or Java, this should feel deeply wrong 😱
And it is worth understanding why Godot works this way rather than just disliking it. The engine’s entire scripting bridge is built on Variant ─ it is how the C++ core, the editor’s Inspector, saved .tscn files, and our scripts all pass values around without knowing each other’s types in advance. Untyped GDScript is not laziness in the language design. It is the engine’s own type system showing through.
That is the explanation. It is not permission.
So why type everything anyway? 👀
Because a Variant costs us four things we actually want ─
- Errors move to runtime. A typo like
player.helathis caught while we type if the variable is typed. Untyped, it becomes a crash mid-playtest. - The code gets slower. When Godot knows the types at parse time it emits optimised instructions instead of going through Variant dispatch on every operation.
- Autocomplete stops working. Godot cannot suggest methods on a value when it has no idea what the value is. Type the variable and the whole API appears.
- We lose the documentation.
var targettells the next reader nothing.var target: Node2Dtells them everything.
The habit is worth building deliberately, and Godot will help us if we ask it to. Two settings are worth turning on right now.
The first makes Godot complain about untyped code. It lives in Project Settings → Debug → GDScript, and we need Advanced Settings switched on to see the section at all. Find Untyped Declaration and set it to Warn. From then on, every bare var health = 100 gets flagged.
The second is in the editor settings rather than the project ─ Text Editor → Completion → Add Type Hints. With that on, the templates Godot generates for us come out typed instead of bare. Small thing, but it means the engine stops fighting the habit we are trying to build.
From here on, every example in this series is typed.
const ─ and it means more than we expect
Constants use const, and the convention is screaming snake case ─
const MAX_HEALTH := 100 const GRAVITY := 980.0 const GAME_TITLE := "Coder's Den Quest"
Here is the part that trips people up. A GDScript const must be resolvable at parse time ─ before the game has started, before any node exists. So this is fine ─
const DOUBLE_GRAVITY := 980.0 * 2
Arithmetic on literals, no problem. But this is not ─
# ERROR ─ nothing here is known until the game is running const START_POSITION := get_viewport_rect().size / 2
For values like that we need a plain var assigned in _ready().
Coming-from note 🤔 ─ GDScript’s
constis much closer to C++’sconstexprthan to C++’sconst. It is not “a reference I promise not to reassign,” which is whatfinalin Java andletin Swift give us. There is no equivalent of that in GDScript ─ a value is either compile-time constant or it is an ordinary variable.
The primitive types
The list is short, and two entries on it are surprising ─
| Type | What it holds |
|---|---|
bool |
true or false |
int |
A 64-bit signed integer |
float |
A 64-bit double-precision number |
String |
Text, UTF-8, mutable |
StringName |
An interned string, written &"name" |
NodePath |
A path to a node, written ^"Player/Sprite2D" |
Look at int and float again. Both are 64-bit, always. There is no long, no short, no byte, no double as a separate thing, and nothing unsigned. When we write float in GDScript we are getting what C++ calls a double.
This catches people in a specific place. Several engine types ─ Vector2, Vector3, Color ─ store their components as 32-bit floats internally in a standard build. So a 64-bit GDScript float loses a little precision on the way into a Vector2. It almost never matters in a game, but it is the kind of thing worth knowing before it confuses us at three in the morning.
There is also no character type. A single character is just a String of length one, and indexing a string hands us back another string, not a number.
The engine types ─ this is the good part 🥳
Now the payoff.
GDScript ships with a set of built-in math types ─ Vector2, Color, Rect2 and friends. The important word there is built-in. These are not library classes we import. They are part of the language itself, sitting alongside int and String, with real operator support baked in.
Here is what that buys us. First, the way our instincts want to write a moving object ─
var pos_x := 100.0 var pos_y := 250.0 var vel_x := 60.0 var vel_y := -40.0 pos_x += vel_x * delta pos_y += vel_y * delta
And now the same thing with Vector2 ─
var position := Vector2(100, 250) var velocity := Vector2(60, -40) position += velocity * delta
Six lines became three, and the three that survived say what they mean. Every x and y pair we would have been maintaining by hand has collapsed into one value.
Vector2 ─ the one we will live in
Building one is unremarkable ─
var a := Vector2(3, 4) var b := Vector2(1, 0) var zero := Vector2.ZERO
The operators are where it earns its place. All of these work directly ─
| Expression | What happens |
|---|---|
a + b |
Adds x to x and y to y |
a - b |
Subtracts component-wise |
a * 2.0 |
Scales both components |
a / 2.0 |
Divides both components |
-a |
Flips the direction |
a == b |
Compares both components |
And the methods cover essentially everything we would otherwise write by hand ─
| Method | What we get |
|---|---|
length() |
The magnitude ─ Vector2(3, 4).length() is 5.0 |
length_squared() |
Magnitude without the square root. Cheaper. |
normalized() |
Same direction, length exactly 1 |
distance_to(b) |
Distance between two points |
direction_to(b) |
A normalized vector pointing at b |
angle() |
The vector’s angle in radians |
rotated(r) |
A copy rotated by r radians |
dot(b) |
Dot product ─ how much two directions agree |
lerp(b, w) |
Interpolates towards b |
That second row is worth a moment 👀 If we are only comparing distances ─ “which enemy is nearest?” ─ we never need the actual number. Comparing length_squared() gives the same answer and skips a square root per check. In a loop over a few hundred objects that adds up, and it is the sort of habit worth having early rather than retrofitting.
There are also constants for the directions we reach for constantly ─
Vector2.ZERO # (0, 0) Vector2.ONE # (1, 1) Vector2.LEFT # (-1, 0) Vector2.RIGHT # (1, 0) Vector2.UP # (0, -1) Vector2.DOWN # (0, 1)
Read those last two again 😱 In 2D, Godot’s Y axis points down, so “up” is negative Y. This comes straight from screen coordinates, where the origin is the top-left corner and rows count downward. Every engine picks a side and Godot picked this one. It will feel wrong for about a week, and then it will feel normal forever.
Vector2i ─ when whole numbers matter
Same shape, integer components ─
var tile := Vector2i(4, 7) var window_size := Vector2i(1920, 1080)
We reach for this whenever fractions would be meaningless ─ grid coordinates, tile indices, pixel positions, window dimensions. Using a Vector2 for a tile index invites a bug where something lands at row 3.9999.
Converting between the two is a plain cast ─
var precise := Vector2(4.7, 7.2) var grid := Vector2i(precise) # (4, 7) ─ truncated, not rounded
Note truncated. 4.7 became 4, not 5. If rounding is what we want, we ask for it ─ Vector2i(precise.round()).
Color ─ and the surprise inside it
There are several ways to build one ─
var a := Color(1.0, 0.5, 0.0) # red, green, blue
var b := Color(1.0, 0.5, 0.0, 0.5) # with alpha
var c := Color("#ff8800") # hex string
var d := Color.ORANGE # named constant
var e := Color8(255, 128, 0) # 0-255 integers
Now the surprise, and it catches almost everyone who has done web or app work ─ the components are floats from 0.0 to 1.0, not integers from 0 to 255.
So this does not do what it looks like ─
# NOT orange ─ these are all wildly above 1.0 var wrong := Color(255, 128, 0)
Godot will not reject it. Values above 1.0 are perfectly legal, because they are how high dynamic range colours are expressed. What we get is a blown-out colour rather than an error 😱
When we have 0-to-255 numbers from a designer or a hex code, the fix is Color8(), which does the conversion for us. Or just paste the hex string in directly ─ Color("#ff8800") handles it.
Once we have one, the useful parts ─
var c := Color("#3d8bfd")
print(c.r, " ", c.g, " ", c.b, " ", c.a) # individual channels
print(c.to_html()) # back to a hex string
print(c.lightened(0.2)) # 20% towards white
print(c.darkened(0.2)) # 20% towards black
print(c.lerp(Color.RED, 0.5)) # halfway to red
Those last three are genuinely handy for UI work ─ hover states, disabled states, damage flashes ─ without hand-rolling any channel arithmetic.
Rect2 ─ a position and a size together
We already used this one without noticing. get_viewport_rect() in the last post returned a Rect2.
var r := Rect2(Vector2(0, 0), Vector2(100, 50)) var same := Rect2(0, 0, 100, 50) # shorthand
It is a position and a size, both Vector2, bundled into one value. And it comes with the geometry questions we would otherwise be writing four-way if statements for ─
r.has_point(Vector2(50, 25)) # is this point inside? r.intersects(other_rect) # do these overlap? r.get_center() # the middle r.grow(10.0) # a copy, 10px bigger on all sides r.merge(other_rect) # smallest rect containing both
There is a Rect2i too, with integer components, for the same reasons Vector2i exists.
What else is in the box
We will meet these as we need them, but it is worth knowing they exist ─
| Type | What it is for |
|---|---|
Vector3, Vector3i |
The same ideas in 3D |
Vector4, Vector4i |
Mostly shader and projection work |
Transform2D, Transform3D |
Position, rotation and scale as one value |
Basis, Quaternion |
3D rotation, without gimbal lock |
Plane, AABB |
3D geometry and bounding volumes |
Coming-from note 🤔 ─ think about what this replaces. In C++ we would pull in GLM or write our own vector struct with operator overloads. In Java we would write a
Vector2class and calla.add(b)forever, because the language has no operator overloading to give us. In C# we would at least getSystem.Numerics, but still as a library. Here it is in the language, with no dependency, no import, and no version to keep track of.
One trap worth knowing now 😱
Everything above comes with a catch, and it is the kind that produces bugs which look impossible until we know the rule.
Two assignments that look identical
Here is the first ─
var a := Vector2(1, 2) var b := a b.x = 99 print(a) # (1, 2) ─ untouched print(b) # (99, 2)
Exactly what we expect. b got a copy, and changing it left a alone.
Now the second, with the same structure ─
var a := [1, 2, 3] var b := a b.append(4) print(a) # [1, 2, 3, 4] ─ we changed a too 😱 print(b) # [1, 2, 3, 4]
Same three lines. Opposite outcome.
Array is a reference type. b is not a copy of the array ─ it is a second name for the same array. Anything we do through one name shows up through the other, because there is only one array in memory.
Which types do which
There is no syntax to tell us which we are holding, so the type itself is the only signal ─
| Behaviour | Types |
|---|---|
| Copied on assignment | bool, int, float, String, StringName, NodePath, and every math type ─ Vector2, Vector2i, Vector3, Color, Rect2, Transform2D and the rest |
| Shared on assignment | Array and typed arrays like Array[int], Dictionary, every Packed*Array, and every object ─ which means every Node and every Resource |
That second row has one entry worth calling out. The Packed*Array family ─ PackedInt32Array, PackedVector2Array and friends ─ are also reference types in Godot 4. If we have used Godot 3 before, this is a behaviour change to unlearn, because they were passed by value there 👀
Getting a real copy
When we want an independent array or dictionary, we ask for one ─
var a := [1, 2, 3] var b := a.duplicate() b.append(4) print(a) # [1, 2, 3] ─ safe print(b) # [1, 2, 3, 4]
Which solves it. Until it doesn’t 🤔
The trap under the trap
duplicate() is shallow. It copies the outer array, but anything inside it that is itself a reference type stays shared ─
var original := [1, 2, [3, 4]] var shallow := original.duplicate() shallow[2].append(99) print(original) # [1, 2, [3, 4, 99]] ─ the inner array was shared 😱 print(shallow) # [1, 2, [3, 4, 99]]
We copied the box, not what was in it. For a genuinely independent structure we pass true ─
var original := [1, 2, [3, 4]] var deep := original.duplicate(true) deep[2].append(99) print(original) # [1, 2, [3, 4]] ─ safe this time 🥳 print(deep) # [1, 2, [3, 4, 99]]
Dictionary works the same way, with the same duplicate(true) for a deep copy. This matters most for saved game state and configuration ─ exactly the nested structures where a shared inner array is hardest to spot.
Function arguments follow the same rule
None of this is special to assignment. Passing a value into a function behaves identically ─
func scale_it(v: Vector2) -> void:
v *= 2.0 # a local copy ─ the caller sees nothing
func add_item(list: Array) -> void:
list.append("sword") # the caller's array really does change
So a function taking an Array can quietly modify its caller’s data. Sometimes that is exactly what we want. When it is not, we duplicate on the way in.
If all of this feels familiar, it should ─ it is the same distinction we worked through in the pass by value and pass by reference post, wearing different clothes. The difference is that C++ made us write & or * and so the intent was visible in the code. GDScript hides it entirely inside the type. Which means we cannot read it off the page ─ we have to know it.
Mini-project ─ let’s make something bounce 🎾
Time to use all of it at once. The goal is the old screensaver ─ an image drifting across the screen, flipping direction whenever it hits an edge.
Every new Godot project ships with icon.svg in its root, so we already have art 🙂
- Scene → New Scene, then Other Node and choose Sprite2D as the root.
- Drag
icon.svgfrom the FileSystem dock onto the Texture slot in the Inspector. - Right-click the node, Attach Script, and save the scene.
And the script ─
extends Sprite2D
const SPEED := 220.0
var velocity := Vector2(1.0, 0.6).normalized()
func _ready() -> void:
position = get_viewport_rect().size / 2
func _process(delta: float) -> void:
position += velocity * SPEED * delta
var bounds := get_viewport_rect().size
if position.x < 0.0 or position.x > bounds.x:
velocity.x = -velocity.x
if position.y < 0.0 or position.y > bounds.y:
velocity.y = -velocity.y
Press F6 🥳
Twelve lines, and almost everything from this post is in there.
const SPEED := 220.0 is a compile-time constant, inferred as float. It is a rate in pixels per second, which is why it gets multiplied by delta later.
Vector2(1.0, 0.6).normalized() gives us a direction of length exactly 1. That separation matters ─ direction lives in velocity, magnitude lives in SPEED. Without the normalized() call, changing the direction would accidentally change the speed too.
get_viewport_rect() hands back a Rect2, and .size pulls a Vector2 out of it. Dividing that vector by 2 gives us the centre of the screen in one expression ─ no x and y arithmetic anywhere.
And velocity.x = -velocity.x is the bounce. Negating one component flips the direction on that axis only, which is exactly what reflecting off a wall does.
Worth noticing what is not in that script. No type declarations spelled out by hand ─ := handled all of them. No separate x and y variables. No manual timer. And every single variable in it has a real static type, which means Godot is checking our work as we type.
So what should we remember? 🤔
Let’s wrap up the key takeaways ─
- There are three declaration forms ─ untyped
var x = 1, explicitvar x: int = 1, and inferredvar x := 1. The last two are typed, the first is not. :=infers a type and locks it in. It is not Python’s walrus operator.- An untyped variable holds a
Variantand can become any type at any time. That is the engine’s own type system leaking through, not a feature to use. - Type everything ─ it catches typos at parse time, runs faster, and turns autocomplete back on.
constmust be resolvable at parse time, so it is closer toconstexprthan tofinal.intandfloatare both 64-bit, and there is no unsigned, nochar, and no smaller numeric type.- The math types are built into the language with real operators.
Vector2.UPis(0, -1), andColorchannels run0.0to1.0, not 0 to 255. - Math types are copied on assignment.
Array,Dictionary, packed arrays and every object are shared ─ andduplicate()is shallow, so nested data needsduplicate(true).
If that all landed, we can now declare anything GDScript has, with a real type on it, and we know which values will surprise us when we pass them around.
Congratulations 🥳🥳🥳
We have typed variables, engine math types, and something bouncing off the walls.
In the next post we will look at operators and control flow ─ including match, which is far more capable than the switch we are used to, and the integer division trap that quietly eats a few hours from everybody at least once 😉
Happy Coding 💻 🎵