Skip to content

L1.6 — Built-in Value Types & Math

What you'll learn

  • Use Vector2 for positions and directions: components, arithmetic, length, normalized, distance_to, lerp.
  • Recognize Color (channels 0–1) and Rect2, and that all three are value types.
  • Reach for the global math helpers: clamp, lerp, min/max, round/floor, the rand* family.
  • Normalize a direction before scaling it, to avoid faster-on-the-diagonal movement.

How it applies

  • Un-normalized input makes diagonals faster. Pressing right-plus-down yields a vector of length ≈1.41; pressing right alone yields length 1. Scale both by the same speed and the player moves ~41% faster diagonally — a real, shippable movement bug that normalized() fixes in one call.
  • Editing a returned vector silently does nothing. A getter that returns a Vector2 returns a copy; writing to .x on that copy changes a throwaway and is lost. Knowing vectors are value types tells you why the change "didn't take" and what to do instead.
  • Color channels are 0–1, not 0–255. Passing 255 where the engine expects 0.0–1.0 produces wrong, over-bright colors. The convention is a frequent first-day surprise; knowing it up front saves a confused debugging session.
  • Unclamped values escape their valid range. Health that can read negative, a volume above 1.0, an index past the end — clamp is the boundary-value guard that keeps a quantity inside the range the rest of the code assumes.

Concepts

Vector2 — the workhorse of 2D

A Vector2 packs two floats, .x and .y. It is used for both positions (a point in space) and directions/velocities (an arrow):

var pos := Vector2(100, 50)
print(pos.x, ", ", pos.y)     # 100, 50

var a := Vector2(1, 0)
var b := Vector2(0, 1)
print(a + b)                  # (1, 1)   — component-wise
print(a * 3)                  # (3, 0)   — scalar scaling

The methods you reach for constantly:

var v := Vector2(3, 4)
print(v.length())                       # 5.0  — magnitude
print(v.normalized())                   # (0.6, 0.8) — same direction, length 1
print(Vector2(0,0).distance_to(v))      # 5.0
print(Vector2(0,0).lerp(v, 0.5))        # (1.5, 2) — halfway toward v

Handy constants exist: Vector2.ZERO, Vector2.ONE, Vector2.RIGHT, Vector2.LEFT, Vector2.UP, Vector2.DOWN. Note that Vector2.UP is (0, -1): in Godot's 2D space the y-axis points down, so "up" is negative y. That coordinate convention gets its own treatment in G2.2; for now just expect UP to be negative y. Vector2i is the integer-component sibling, for grid coordinates and sizes.

Normalizing a direction before you scale it

A direction assembled from axis input does not have length 1. To move at a fixed speed regardless of direction, normalize first, then scale:

var direction := Vector2(x_input, y_input)   # could be length 1.41 on a diagonal
var step := direction.normalized() * speed   # now exactly `speed` in any direction

Example

The bug this prevents, made concrete:

# WRONG: diagonal is faster
var dir := Vector2(1, 1)        # length ≈ 1.41
var step := dir * 200           # length ≈ 283 — too fast

# RIGHT: every direction moves at 200
var step2 := dir.normalized() * 200   # length exactly 200

A player cannot articulate "my diagonal speed is 1.41× my cardinal speed," but they feel that the character lurches when moving diagonally. The engine's input helper Input.get_vector (G2.5) normalizes for you; when you build a direction by hand, you must.

Color and Rect2

A Color has four channels — red, green, blue, alpha — each a float from 0.0 to 1.0 (not 0–255):

var red := Color(1, 0, 0)          # opaque red
var faded := Color(1, 1, 1, 0.5)   # half-transparent white
var hex := Color("ff8800")         # also accepts hex strings

A Rect2 is a rectangle — a position (Vector2) and a size (Vector2) — with helpers like has_point(p) and intersects(other). You meet it in UI layout and hit-testing.

These are value types

Vector2, Color, and Rect2 are value types (L1.5): copied on assignment and passed by value. That has one practical consequence worth stating now:

var p := Vector2(1, 2)
var q := p          # independent copy
q.x = 99
print(p.x)          # 1 — p untouched

The flip side is the "why didn't my change take" trap: if a function or property returns a Vector2, it returns a copy, so writing to that copy's .x changes a temporary and is discarded. To change a node's position you assign the whole vector back (node.position = new_vec) or assign the component on the property directly (node.position.x = 5, which the property supports); you do not mutate the result of an arbitrary vector-returning call and expect it to persist.

Global math helpers

Godot exposes a large set of math functions globally (no prefix needed):

clamp(value, 0.0, 1.0)        # keep value within [0, 1]  (clampi / clampf for typed variants)
lerp(a, b, t)                 # linear interpolation, t in [0, 1]
min(a, b)        max(a, b)    # also mini/maxi/minf/maxf for typed variants
abs(x)   round(x)   floor(x)  ceil(x)   # roundi/floori/ceili return ints
sqrt(x)   pow(base, exp)
deg_to_rad(d)    rad_to_deg(r)
PI                            # the constant

For randomness:

randf()                 # float in [0, 1)
randf_range(1.0, 5.0)   # float in [1, 5)
randi_range(1, 6)       # int in [1, 6] inclusive — a die roll

clamp is the boundary guard you reach for most: clamp health to [0, max_health], clamp a volume to [0, 1], clamp an index to a valid range. The typed variants (clampi, clampf, maxf) avoid accidental int/float surprises (L1.3) and appear throughout the game books.

Walkthrough

Use your L1.1 scene and script.

  1. Make var dir := Vector2(1, 1). Print dir.length() (≈1.41), then (dir * 200).length() (≈283), then (dir.normalized() * 200).length() (200). You have just measured the diagonal-speed bug and its fix.
  2. Demonstrate value-copy: copy a Vector2 to a second name, change the copy's .x, print the original, confirm it is unchanged.
  3. Clamp experiments: print(clamp(150, 0, 100)) and print(clamp(-20, 0, 100)); confirm 100 and 0. Then roll a die ten times with randi_range(1, 6) in a loop.
  4. Make a Color(255, 0, 0) and a Color(1, 0, 0), set them on something visible later if you like — for now just note that the first is out of the 0–1 range and will not mean "bright red ×255."

Optional sanity check

Write a function func get_offset() -> Vector2: return Vector2(0, 0) and then, in _ready, try get_offset().x = 5 followed by print(get_offset()). It still prints (0, 0) — you mutated the returned copy and threw it away. That is the value-type "returned copy" trap in two lines.

Self-check quiz

Q1 — Pressing right+down gives Vector2(1, 1). You move with velocity = dir * speed. What is wrong, and the fix?

A. Nothing is wrong; diagonal movement should be faster. B. The diagonal vector has length ≈1.41, so diagonal speed is ~41% too high; fix with dir.normalized() * speed. C. Vector2(1, 1) is invalid; you must use Vector2(1.0, 1.0). D. You must divide by 2 for diagonals specifically.

Reveal answer

B. Vector2(1, 1).length() is ≈1.41, so scaling it by speed produces a velocity ~41% longer than a cardinal direction. normalized() rescales the direction to length 1 so every direction moves at speed. A accepts a real defect. C is false (1 is fine; it becomes a float in a Vector2). D is a hack that only fixes the exact 45° case, not arbitrary directions.

Q2 — Color(1, 1, 1) represents what, and what does Color(255, 255, 255) do?

A. Both are white; channels accept 0–255. B. Color(1,1,1) is white (channels are 0–1); Color(255,255,255) is out-of-range and not the intended white. C. Color(1,1,1) is black; you need 255 for white. D. Both error; you must use hex strings.

Reveal answer

B. Color channels run 0.0–1.0, so Color(1,1,1) is full-intensity white. Passing 255 is far outside the expected range and does not mean "white ×255" — it is the classic mistake for people arriving from 0–255 color APIs. C inverts the scale. D is false — numeric constructors are valid; hex strings are merely an alternative.

Q3 — var p := Vector2(2, 3), var q := p, q.x = 0. What is p.x?

A. 0, because q references p. B. 2, because Vector2 is a value type and q is an independent copy. C. Error — you cannot assign to .x. D. null.

Reveal answer

B. Vector2 is a value type, so q := p copies it; mutating q.x leaves p untouched. A would be true for a reference type like Array, not for a vector. C is wrong — .x is assignable on a vector you own. D invents a null that never appears.

Integration question

Q4 — open

A movement routine reads input into var dir := Vector2(ix, iy), then tries to cap speed with dir.normalized().x = clamp(dir.x, -1, 1) before doing position += dir * speed * delta. The character still moves faster on diagonals. Identify the two distinct mistakes — one about value types, one about normalization — and write the corrected two lines.

Reveal expected answer

Mistake 1 — writing to a returned copy. dir.normalized() returns a new Vector2 (a value type); assigning to its .x mutates that throwaway copy and discards it. dir is never changed, so nothing is normalized. Mistake 2 — clamping components instead of normalizing the vector. Clamping .x and .y each to [-1, 1] still leaves (1, 1) with length 1.41; per-component clamping is not the same as scaling the whole vector to length 1. The correct approach normalizes the direction and then scales it:

var step := dir.normalized() * speed * delta
position += step

normalized() returns the unit-length direction; multiplying by speed * delta gives a frame-rate-independent step of equal magnitude in every direction. The chapter's two threads — vectors are value types (so a returned vector is a copy) and a direction must be normalized before scaling — are exactly the two things this code got wrong.