Operators & Control Flow

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 for loop we have ever written.
  • match, which shares a job description with switch and 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 is value_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 an if block 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 elifelse 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 no do...while, so a loop that must run at least once becomes while true: with a break at 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 existwhile 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 / 2 is 3, not 3.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. is checks type, in checks membership, as casts safely to null, and is_empty() beats relying on truthiness.
  • It is elif, parentheses are omitted, and the conditional expression is a 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 no enumerate() ─ loop over .size() when the index matters.
  • Nested loops are fine, but break escapes only the innermost one. There are no labelled breaks ─ extract a function and return.
  • match has no fallthrough and no break, matches array and dictionary structure, binds values with var, guards with when ─ and is stricter than ==, so 1 will not match 1.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 💻 🎵

Leave a Comment

Your email address will not be published. Required fields are marked *