Skip to content

L1.3 — Operators & Control Flow

What you'll learn

  • Use GDScript's operators, and avoid the integer-division trap (5 / 2 == 2).
  • Branch with if / elif / else, and loop with while and for over ranges and collections.
  • Match a value against patterns with match, including the wildcard _.
  • Choose match over a long if/elif chain when the branches test one value against constants.

How it applies

  • Integer division silently rounds. 5 / 2 is 2, not 2.5, because both operands are int. A damage or ratio formula written without thinking about operand types produces numbers that are wrong by a fraction every time — a defect that hides because no error fires. Knowing the rule is the fix.
  • A missing else/default is an unhandled state. Branching that covers the cases you thought of and silently does nothing for the rest is how an enemy freezes in an unknown state or a menu does nothing on an unexpected input. A match with a _ arm forces you to decide what "everything else" does — the same instinct as a default case in a test matrix.
  • The wrong loop bound is an off-by-one. for i in range(n) runs 0..n-1; reaching for the wrong range form puts you one short or one over, the single most common loop bug. The genre is full of "the last item never spawns" reports that trace to exactly this.
  • match documents intent that if chains bury. A match on a state enum reads as a table of cases; the equivalent if/elif chain reads as prose you must parse line by line. Reviewers catch a missing case in a table faster than in a chain.

Concepts

Operators, and the integer-division trap

The arithmetic, comparison, and logical operators are what you expect: + - * / % (modulo), == != < > <= >=, and and / or / not (the keyword spellings are idiomatic; && || ! also work). The one that surprises people:

var a := 5 / 2        # 2  — both operands are int, so integer division
var b := 5.0 / 2      # 2.5 — one operand is float, so float division

If either operand is a float, the result is a float. If both are int, you get integer division — the fraction is discarded, not rounded. When a calculation should be fractional, make sure at least one operand is a float (write 2.0, or float(x)). This is the most common silently-wrong arithmetic bug in GDScript.

if / elif / else

Standard branching, with Python-like indentation and a colon:

if hp <= 0:
    die()
elif hp < 20:
    play_low_health_warning()
else:
    pass        # pass = "do nothing", a placeholder for an empty block

You already know conditionals; the GDScript notes are the elif spelling, the trailing :, the indentation (tabs by default), and pass for a block that must exist but does nothing.

while and for

while repeats while a condition holds:

while accumulator >= TICK_RATE:
    accumulator -= TICK_RATE
    grant_one_tick()

for iterates over a sequence — a range of numbers or the elements of a collection:

for i in range(3):          # 0, 1, 2  — range(n) stops before n
    print(i)

for i in range(1, 4):       # 1, 2, 3  — range(start, stop)
    print(i)

for enemy in enemies:       # each element of the array `enemies`
    enemy.alert()

range(n) yields 0 to n-1 — it stops before n. That boundary is where off-by-one bugs live: range(3) runs three times (0,1,2), and if you meant "1 through 3" you need range(1, 4). break exits the loop early; continue skips to the next iteration.

Example

The idle book's tick loop uses while, not if, on purpose:

while accumulator >= TICK_RATE:
    accumulator -= TICK_RATE
    grant_one_tick()

If a slow frame banked enough time for three ticks, while grants all three; an if would grant one and leak the rest. The choice of loop construct is a correctness decision, not a style one — the kind of thing you meet again in G2.1.

match — pattern dispatch

match compares one value against a series of patterns and runs the first that matches. There is no fall-through: the matched block runs, then control leaves the match.

match state:
    State.IDLE:
        play("idle")
    State.MOVE:
        play("run")
    _:
        push_warning("unhandled state")

The patterns can be literals (1, "hello"), constants (State.IDLE), or the wildcard _, which matches anything and acts as the default — conventionally last. You can list several patterns on one arm separated by commas (State.MOVE, State.RUN:). match also supports array and dictionary patterns for destructuring, but the constant-dispatch form above is what the game books use (notably the M2.4 state machine), so it is the form to own first.

Example

The same dispatch as an if/elif chain versus a match:

# chain — reads as prose, easy to drop a case
if state == State.IDLE:
    play("idle")
elif state == State.MOVE:
    play("run")
# match — reads as a table, the missing _ arm is conspicuous
match state:
    State.IDLE: play("idle")
    State.MOVE: play("run")

When every branch tests the same value against constants, match is the clearer tool. When the branches test different conditions (hp <= 0, then time > limit, then is_grounded), an if/elif chain is correct and match does not apply.

Walkthrough

Use your L1.1 scene and script; put the experiments in _ready.

  1. Print 5 / 2, then 5.0 / 2. Confirm 2 versus 2.5. Then write a "percentage" calculation that is wrong because of integer division (e.g. part / whole * 100 with int operands), and fix it by making one operand a float.
  2. Write a for i in range(3) loop that prints i, and predict the output before running. Then change it to print 1, 2, 3 by switching to range(1, 4).
  3. Declare var state := 1. Write a match state: with arms for 0, 1, and a _ default, each printing a different line. Run it, then change state to 5 and confirm the _ arm fires.
  4. Rewrite the same dispatch as an if/elif/else chain. Compare the two side by side — which makes a missing case more obvious?

Optional sanity check

Remove the _ arm from your match, set state to a value none of the arms cover, and run. Nothing happens — no error, no output. That silent no-op is exactly the "unhandled state" defect the _ arm exists to prevent. Put it back.

Self-check quiz

Q1 — What does 5 / 2 evaluate to in GDScript, and why?

A. 2.5, because division always produces a float. B. 2, because both operands are int, so it is integer division (the fraction is discarded). C. 3, because it rounds to nearest. D. An error, because you cannot divide odd numbers evenly.

Reveal answer

B. With two int operands, / is integer division and truncates toward zero, giving 2. A would be true only if at least one operand were a float (5.0 / 2). C is wrong — integer division truncates, it does not round. D invents a constraint that does not exist.

Q2 — for i in range(1, 4): prints which values of i?

A. 1, 2, 3, 4 B. 1, 2, 3 C. 0, 1, 2, 3 D. 1, 4

Reveal answer

B. range(start, stop) begins at start and stops before stop, so range(1, 4) yields 1, 2, 3. A includes 4, the classic off-by-one (the stop is exclusive). C is range(0, 4) / range(4) behavior, ignoring the start of 1. D misreads range as "from 1 to 4 in one step."

Q3 — When is match the better choice over an if/elif chain?

A. Always — match is faster than if. B. When every branch tests the same value against constant patterns. C. When each branch tests a different boolean condition. D. Only inside loops.

Reveal answer

B. match shines when one value is dispatched against constants (a state enum, a type tag), because it reads as a table and the wildcard arm makes the default explicit. C is the case where match does not apply — differing conditions belong in an if/elif chain. A overstates it (clarity, not guaranteed speed, is the reason), and D invents a restriction.

Integration question

Q4 — open

An enemy script computes var armor_pct := armor / (armor + 50) * 100 with armor typed int, then dispatches behavior with an if/elif chain over a state variable, with no final else. Two bugs are latent here. Name both, say which chapter idea each one violates, and give the one-line fix for each.

Reveal expected answer

Bug 1 — integer division. armor, 50, and the sum are all int, so armor / (armor + 50) is integer division and is almost always 0 (the numerator is smaller than the denominator), making armor_pct always 0. Fix: force float division, e.g. armor / float(armor + 50) * 100 or write 50.0. This violates the integer-division concept.

Bug 2 — no default branch. The if/elif chain handles known states and silently does nothing for any state it does not list — an unhandled-state no-op. Fix: add a final else (or convert to a match with a _ arm) that at least logs the unexpected value. This violates the "a missing default is an unhandled state" point. Both bugs share a theme: they fail silently, which is why neither produces an error and both are caught only by reasoning about operand types and case coverage — the exact instincts this chapter builds.