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 withwhileandforover ranges and collections. - Match a value against patterns with
match, including the wildcard_. - Choose
matchover a longif/elifchain when the branches test one value against constants.
How it applies
- Integer division silently rounds.
5 / 2is2, not2.5, because both operands areint. 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. Amatchwith 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)runs0..n-1; reaching for the wrongrangeform 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. matchdocuments intent thatifchains bury. Amatchon a state enum reads as a table of cases; the equivalentif/elifchain 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:
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:
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.
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.
- Print
5 / 2, then5.0 / 2. Confirm2versus2.5. Then write a "percentage" calculation that is wrong because of integer division (e.g.part / whole * 100with int operands), and fix it by making one operand a float. - Write a
for i in range(3)loop that printsi, and predict the output before running. Then change it to print1, 2, 3by switching torange(1, 4). - Declare
var state := 1. Write amatch state:with arms for0,1, and a_default, each printing a different line. Run it, then changestateto5and confirm the_arm fires. - Rewrite the same dispatch as an
if/elif/elsechain. 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.