Hi everyone ✋
In the previous post we covered variables, static typing, and the built-in engine types ─ and we finished with the Godot icon bouncing around the screen.
Today we do operators and control flow.
This is the post where GDScript looks most like Python, which makes it the post where the resemblance is most dangerous 😱 The shapes are all familiar ─ if, elif, for, while ─ and then three or four things behave in a way our fingers do not expect.
Every section below ends with a straight translation table from the C family ─ C, C++, Java, C# ─ into GDScript. Read the section, then keep the table for when we are actually typing.
Here is the plan ─
- Operators, including two that are missing and one that will silently give us the wrong number.
- Conditionals, and the ternary that is not a ternary.
- Loops ─ including the shortest
forloop we have ever written. match, which shares a job description withswitchand almost nothing else.
And we will finish with a working state machine 🥳
Let’s take a deep dive 🤿
Arithmetic operators
The everyday set behaves exactly as we expect ─
var a := 17 var b := 5 print(a + b) # 22 print(a - b) # 12 print(a * b) # 85 print(a % b) # 2 print(a ** 2) # 289
That last one is the power operator. No Math.pow, no #include <cmath> ─ ** is built in.
Compound assignment works too, and it is not optional, because there is no ++ and no -- ─
# ERROR ─ this is not GDScript score++ # This is score += 1
Honestly, this is a reasonable design choice. The difference between i++ and ++i has caused more interview questions than useful code, and dropping both removes an entire category of confusion.
| What we want | C / C++ / Java / C# | GDScript |
|---|---|---|
| The basic four | + - * / |
+ - * / |
| Modulo | % |
% for ints, fmod() for floats |
| Power | pow(a, b) |
a ** b |
| Increment | i++ or ++i |
Does not exist ─ use i += 1 |
| Decrement | i-- or --i |
Does not exist ─ use i -= 1 |
| Compound assignment | += -= *= /= %= |
Identical |
| Bitwise | & | ^ ~ << >> |
Identical |
The integer division trap 😱
Here is the one that quietly eats an afternoon ─
var a := 7 / 2 print(a) # 3
Not 3.5. Three.
Two integers divided give an integer, with the remainder discarded. That is exactly how C, C++, Java, and C# behave ─ but the code on screen looks like Python, where the answer would be 3.5. Our eyes read one language and our results come from another.
Where it really hurts is when the numbers are hidden behind names ─
var total_score := 7 var rounds := 2 var average := total_score / rounds # 3, not 3.5 ─ silently wrong
No error. No crash. Just a slightly wrong number flowing through the rest of the game.
The fix is to make sure at least one side is a float ─
print(7.0 / 2) # 3.5 print(7 / 2.0) # 3.5 print(float(total_score) / rounds) # 3.5
Godot does help here ─ it flags integer division in the script editor with a warning. It is a warning though, not an error, and warnings are easy to scroll past.
Modulo has a matching surprise with negative numbers ─
print(-7 % 3) # -1 print(posmod(-7, 3)) # 2
Plain % keeps the sign of the left operand. When we are wrapping around a grid or a circular array we almost always want the second answer, so posmod() is worth learning before the bug rather than after.
| Expression | C / C++ / Java / C# | Python | GDScript |
|---|---|---|---|
7 / 2 |
3 |
3.5 |
3 |
7.0 / 2 |
3.5 |
3.5 |
3.5 |
-7 % 3 |
-1 |
2 |
-1 |
On arithmetic, GDScript sides with C every time. It only looks like the other column 👀
Comparison and logic
Comparison is unremarkable ─ ==, !=, <, >, <=, >=, all doing their usual jobs.
Boolean logic is where the fingers have to relearn something. GDScript uses the word forms ─
if health > 0 and not is_stunned:
move()
if is_grounded or coyote_time > 0.0:
jump()
These are what Godot’s own style guide uses, what the engine documentation uses, and what we will use throughout this series.
Three more operators show up constantly in engine code and deserve a proper introduction ─
# "is" ─ type checking
if node is Sprite2D:
print("it's a sprite")
# "in" ─ membership
if "sword" in inventory:
print("armed")
if "hp" in stats:
print("this dictionary has an hp key")
# "as" ─ safe cast
var sprite := node as Sprite2D
if sprite != null:
sprite.modulate = Color.RED
as is the polite version of a cast. If the object is not actually a Sprite2D, we get null back instead of a crash ─ which is why it pairs so naturally with a null check on the very next line.
And one habit worth adopting early ─
# say what we mean
if inventory.is_empty():
print("nothing here")
Relying on a bare if inventory: to mean “not empty” is the Python instinct, and it is exactly the sort of thing that has shifted between engine versions. is_empty() never surprises us.
| What we want | C / C++ / Java / C# | GDScript |
|---|---|---|
| Equal, not equal | == != |
== != |
| Ordering | < > <= >= |
Identical |
| Logical AND | && |
and |
| Logical OR | || |
or |
| Logical NOT | ! |
not |
| The null literal | NULL, nullptr, null |
null |
| Type check | instanceof, is |
is |
| Cast without crashing | as, dynamic_cast |
as ─ yields null on failure |
| Is it in the collection? | list.contains(x) |
x in list |
| Is the collection empty? | list.isEmpty(), .empty() |
list.is_empty() |
Conditionals
The basic shape, with a game-flavoured example ─
var health := 45
var has_potion := true
if health <= 0:
print("game over")
elif health < 50 and has_potion:
print("drink the potion")
elif health < 50:
print("careful now")
else:
print("all good")
Three things to notice in there.
The keyword is elif. Not else if ─ that is a parse error, not a style preference. This one will catch us at least twice.
No parentheses around the condition. They are allowed, and Godot will not complain, but nobody writes them and they add nothing. if (health <= 0): works and looks out of place.
Braces are gone. The colon opens the block and indentation closes it, exactly as in the function bodies we have been writing since post two.
Single-line bodies and guard clauses
A short body can sit on the same line as its condition ─
if health <= 0: return
That is worth knowing because of what it does to nesting. Compare these two ─
# nested ─ the real work drifts rightwards
func take_damage(amount: int) -> void:
if is_alive:
if not is_invincible:
if amount > 0:
health -= amount
# guard clauses ─ flat, and the exits are obvious
func take_damage(amount: int) -> void:
if not is_alive: return
if is_invincible: return
if amount <= 0: return
health -= amount
Since indentation is the block structure in GDScript, deep nesting hurts more here than it does in a braced language. Guard clauses are not just tidier ─ they keep the code from marching off the right-hand side of the screen 🙂
The conditional expression
There is a ternary, but it does not look like the one we know ─
var status := "alive" if health > 0 else "dead" var speed := 600.0 if is_dashing else 120.0 var colour := Color.RED if health < 30 else Color.WHITE
The condition sits in the middle instead of the front. It reads oddly for about a day, and then it stops registering.
Coming-from note 🤔 ─ there is no
? :in GDScript at all. The form isvalue_if_true if condition else value_if_false. What it has over? :is that it resists nesting ─ a nested conditional expression is so visibly ugly that we simply write anifblock instead, which is what we should have done anyway.
One more note before the table. When a chain of elif branches is all testing the same variable against different values, that is usually a sign we want match instead ─ which is the next section.
| What we want | C / C++ / Java / C# | GDScript |
|---|---|---|
| If | if (x > 0) { ... } |
if x > 0: |
| Else if | else if |
elif ─ else if is a parse error |
| Else | else { ... } |
else: |
| Grouping a block | Braces | Indentation |
| Condition parentheses | Required | Optional, and always omitted |
| One-line body | if (!ok) return; |
if not ok: return |
| Ternary | cond ? a : b |
a if cond else b |
Loops
Here is the shortest loop we will ever write ─
for i in 10:
print(i) # 0 through 9
An integer is directly iterable. No range(), no three-part header, no counter to declare, no off-by-one to get wrong. This single form replaces most of what a C-style for was doing.
When we need a start, a stop, or a step, range() takes over ─
for i in range(2, 10):
print(i) # 2, 3, 4 ... 9
for i in range(0, 10, 2):
print(i) # 0, 2, 4, 6, 8
for i in range(10, 0, -1):
print(i) # 10, 9, 8 ... 1
Same rules as everywhere else ─ the start is included, the stop is not.
Walking a collection
for iterates over anything that holds things ─
var inventory := ["sword", "shield", "potion"]
for item in inventory:
print(item)
Dictionaries hand us the keys, not the values ─
var stats := {"hp": 100, "mp": 30, "gold": 5}
for key in stats:
print(key, " = ", stats[key])
And a string gives us one-character strings, since GDScript has no character type ─
for c in "hi":
print(c) # "h" then "i"
When we need the index as well
There is no enumerate() here, so when both the position and the value matter we loop over the size and index in ─
for i in inventory.size():
print(i, ": ", inventory[i])
# 0: sword
# 1: shield
# 2: potion
This is a small place where the Python resemblance runs out and we go back to doing it by hand.
while, break and continue
while behaves as expected ─
var count := 0
while count < 5:
count += 1
print(count) # 1 2 3 4 5
And the two escape hatches work in both loop kinds ─
for i in 10:
if i == 3:
continue # skip this one, keep going
if i == 6:
break # stop the loop entirely
print(i) # 0 1 2 4 5
Nested loops ─ yes, and here is the catch
Loops nest to any depth we like. Indentation is the only thing keeping track ─
for y in 3:
for x in 3:
print(Vector2i(x, y))
# (0, 0) (1, 0) (2, 0)
# (0, 1) (1, 1) (2, 1)
# (0, 2) (1, 2) (2, 2)
That pattern ─ an outer loop over rows, an inner loop over columns, a Vector2i built from both ─ is how we will walk every grid, tilemap, and inventory screen we ever write.
Now the catch, and it is a real one 😱 break only exits the loop it is standing in. In a nested loop, breaking out of the inner one drops us straight back into the outer one, which happily continues ─
for y in 3:
for x in 3:
if grid[y][x] == "treasure":
print("found it")
break # escapes the x loop only
# ...and we are right back here, on the next y
In Java we would label the outer loop and break outer;. GDScript has no labelled break, so we get two options.
The clumsy one is a flag ─
var found := false
for y in 3:
for x in 3:
if grid[y][x] == "treasure":
found = true
break
if found:
break
The clean one is to put the search in its own function and return out of it, because return leaves everything ─
func find_treasure() -> Vector2i:
for y in 3:
for x in 3:
if grid[y][x] == "treasure":
return Vector2i(x, y)
return Vector2i(-1, -1) # not found
Reach for the second one. A nested search that needs a flag is usually a function waiting to be extracted, and we will be doing a lot more of that in the next post.
Coming-from note 🤔 ─ three absences worth writing down. There is no C-style
for (int i = 0; i < n; i++). There is nodo...while, so a loop that must run at least once becomeswhile true:with abreakat the bottom. And there are no labelled breaks, as above.
| What we want | C / C++ / Java / C# | GDScript |
|---|---|---|
| Count 0 to n-1 | for (int i = 0; i < n; i++) |
for i in n: |
| Start, stop, step | for (int i = 2; i < 10; i += 2) |
for i in range(2, 10, 2): |
| Count downwards | for (int i = n; i > 0; i--) |
for i in range(n, 0, -1): |
| Walk a collection | for (auto x : list) |
for x in list: |
| Index and value together | The classic indexed for |
for i in list.size(): then list[i] |
| While | while (x) { ... } |
while x: |
| Do-while | do { ... } while (x); |
Does not exist ─ while true: plus break |
| Nested loops | Braces track the depth | Indentation tracks the depth |
| Break, continue | break continue |
Identical ─ innermost loop only |
| Escape nested loops | break outer; |
Does not exist ─ use a flag, or return |
match ─ the good part 🥳
Now the reason this post exists.
match occupies the same slot in the language as switch, and if we treat it as a renamed switch we will get correct code while missing most of what it does.
The basic shape
match direction:
"north":
print("going up")
"south", "down":
print("going down")
_:
print("lost")
The underscore is the default case, and a comma groups several patterns into one branch. Patterns are tested top to bottom, the first match runs, and execution continues after the whole block.
There is no fallthrough in GDScript, and no way to ask for it. Which means no break either ─ it would have nothing to do. Every accidental-fallthrough bug we have ever written simply cannot be expressed here 🥳
In switch |
In match |
|---|---|
switch (x) { |
match x: |
case 1: |
1: ─ the keyword is gone |
break; |
Delete it entirely |
default: |
_: |
Stacked case labels |
Comma-separated patterns |
And now what switch could never do
| Capability | switch |
match |
|---|---|---|
| Fallthrough | Yes, and usually by accident | Never |
| Match on strings | Java and C# yes, C and C++ no | Yes |
| Match the shape of an array | No | Yes ─ ["move", ..] |
| Match the shape of a dictionary | No | Yes ─ {"type": "damage"} |
| Pull a value out while matching | No | Yes ─ var direction |
| Add a condition to a case | No | Yes ─ when |
Those last four rows are the whole reason to stop thinking of it as a switch. Let’s take them one at a time.
Matching structure
match command:
[]:
print("empty command")
["quit"]:
print("exiting")
["move", "north"]:
print("moving north")
["move", ..]:
print("some kind of move")
The .. means “and anything else after this,” so ["move", ..] matches any array starting with "move", whatever its length.
Dictionaries work the same way ─
match event:
{"type": "damage", "amount": 0}:
print("harmless hit")
{"type": "damage"}:
print("took damage")
{"type": "heal", ..}:
print("healed")
A dictionary pattern matches when the listed keys are present with the listed values. Adding .. permits extra keys beyond the ones we named.
Binding what we matched
match command:
["move", var direction]:
print("moving ", direction)
["say", var message, var volume]:
print(message, " at volume ", volume)
The pattern tests the structure and pulls the pieces out in one step. Writing that with switch would take a length check, an index access, and a temporary variable.
Guards with when
match damage:
var d when d > 100:
print("massive hit")
var d when d > 0:
print("took ", d)
_:
print("no damage")
The pattern binds first, then the when clause decides whether the branch actually runs. If the guard fails, matching carries on to the next pattern.
One gotcha worth knowing 👀
match is stricter about types than == is. An int will not match a float pattern ─
var value := 1
match value:
1.0:
print("this will NOT run")
1:
print("this will 🥳")
Meanwhile 1 == 1.0 is perfectly true. So a value that compares equal may still fail to match. The one exception is text ─ a String and a StringName holding the same characters do match each other.
Mini-project ─ a state machine 🎛️
Let’s put match to work on the job it does best.
We will take the bouncing sprite from last post and give it three states that cycle every two seconds ─ drifting, dashing, and frozen ─ each with its own speed and colour.
One new thing first. GDScript has enums, and they behave exactly as we would expect ─
enum State { DRIFT, DASH, FROZEN }
Named integer constants, grouped under a type we can use in declarations. We will do more with them later, but that one line is all we need today.
Open the bouncing Sprite2D scene from last post and replace the script with this ─
extends Sprite2D
enum State { DRIFT, DASH, FROZEN }
const STATE_DURATION := 2.0
var state: State = State.DRIFT
var velocity := Vector2(1.0, 0.6).normalized()
var timer := 0.0
func _ready() -> void:
position = get_viewport_rect().size / 2
func _process(delta: float) -> void:
timer += delta
if timer >= STATE_DURATION:
timer = 0.0
match state:
State.DRIFT:
state = State.DASH
State.DASH:
state = State.FROZEN
State.FROZEN:
state = State.DRIFT
var speed := 0.0
match state:
State.DRIFT:
speed = 120.0
modulate = Color.CYAN
State.DASH:
speed = 600.0
modulate = Color.ORANGE
State.FROZEN:
speed = 0.0
modulate = Color.GRAY
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 and watch it drift, dash, freeze, repeat 🥳
Two match blocks, doing two different jobs.
The first is a transition table ─ given the current state, what comes next. Written as if/elif it would be three comparisons against the same variable, and adding a fourth state means finding the right place to wedge it in. As a match, the whole state graph is readable at a glance.
The second is a behaviour table ─ given the current state, how do we look and move. Same structure, different question.
That separation is the useful part. State machines get messy when transition logic and behaviour logic get tangled together, and putting each in its own match keeps them apart almost by accident.
Worth trying ─ add a fourth state to the enum, then add one branch to each match. Two small edits, in obvious places, and nothing else in the script needs to change.
So what should we remember? 🤔
Let’s wrap up the key takeaways ─
- There is no
++or--. Use+= 1. 7 / 2is3, not3.5. On division and modulo, GDScript behaves like C, not like the Python it resembles.posmod()when wrapping,fmod()for floats.- Logic uses the words
and,or,not.ischecks type,inchecks membership,ascasts safely tonull, andis_empty()beats relying on truthiness. - It is
elif, parentheses are omitted, and the conditional expression isa if cond else b─ there is no? :. - Since indentation is the block structure, guard clauses matter more here than in a braced language.
for i in 10:iterates 0 to 9, dictionaries iterate keys, and there is noenumerate()─ loop over.size()when the index matters.- Nested loops are fine, but
breakescapes only the innermost one. There are no labelled breaks ─ extract a function andreturn. matchhas no fallthrough and nobreak, matches array and dictionary structure, binds values withvar, guards withwhen─ and is stricter than==, so1will not match1.0.
If that all landed, we can now express any control flow GDScript is capable of ─ and match in particular is worth reaching for more often than our switch instincts suggest.
Congratulations 🥳🥳🥳
We have a working state machine, and it is only about thirty lines.
In the next post we will look at functions ─ default arguments, why there is no method overloading and what to do about it, and Callable, which turns functions into values we can pass around 😉
Happy Coding 💻 🎵