Skip to content

G2.2 — The 2D Coordinate System & Transforms

What you'll learn

  • Work in Godot's 2D space: origin top-left, +x right, +y down, angles in radians.
  • Use position (relative to parent) versus global_position (in world space).
  • Set rotation in radians (rotation) or degrees (rotation_degrees) without mixing them up.
  • Understand that a parent's transform compounds onto its children, and convert with to_local/to_global.

How it applies

  • Assuming y-up inverts everything vertical. Coming from math class or a y-up engine, you expect "up" to be +y; in Godot 2D it is −y, so code that "moves up" moves down and jumps go the wrong way. Internalizing y-down once prevents a whole category of flipped-axis bugs.
  • Local versus global is a teleport bug. Setting global_position when you meant position (or vice versa) on a parented node sends it to the wrong place — often off-screen. The two are different coordinate spaces, and using the wrong one is a common "why did it jump there?" report.
  • Radians-versus-degrees spins wildly. Assigning a degree value to rotation (which is radians) rotates ~57× too far. Knowing which property uses which unit avoids objects that whirl instead of turning.
  • Scaling a parent squashes its children — and their physics. Because the transform compounds, scaling a parent scales every child, including collision shapes, which distorts hit detection. Knowing transforms cascade explains surprises that look unrelated to the change you made.

Concepts

The 2D coordinate system: y points down

Godot's 2D space has its origin at the top-left. +x goes right, +y goes down. This is the screen convention (pixels counted from the top), not the math-class convention (y up). Consequences:

  • "Up" on screen is negative y — which is why Vector2.UP is (0, -1) (L1.6).
  • Angles are measured in radians, increasing clockwise (a side effect of y pointing down). An angle of 0 points along +x (to the right).

You do not have to like it; you have to expect it. The most common beginner error in 2D motion is writing "jump" as +y and watching the character sink.

position versus global_position

A Node2D has two position properties:

  • position — the node's location relative to its parent.
  • global_position — the node's location in world space (relative to the tree's root).

For a node with no moved parent, they coincide. For a parented node they differ:

# Parent at world (100, 100); child's local position is (10, 0).
print(child.position)         # (10, 0)   — relative to the parent
print(child.global_position)  # (110, 100) — in the world

Set position to place a node within its parent; set global_position to place it at an absolute world point regardless of parent. Reaching for the wrong one teleports the node.

Rotation and scale, and their units

  • rotation is in radians; rotation_degrees is the same rotation in degrees. Set whichever is convenient, but do not assign a degree number to rotation:

    node.rotation_degrees = 90      # quarter turn, readable
    node.rotation = PI / 2          # the same quarter turn, in radians
    node.rotation = 90              # BUG: 90 radians ≈ 14 full turns
    

    Convert with deg_to_rad / rad_to_deg (L1.6) when you have one and need the other.

  • scale is a Vector2 multiplier (Vector2(1, 1) is unscaled). Scaling stretches the node and its children.

Transforms compound down the tree

Each Node2D carries a Transform2D — its combined position, rotation, and scale — and a child's transform is applied on top of its parent's. Move, rotate, or scale a parent and every descendant moves, rotates, or scales with it. This is the mechanism behind grouping (G1.2): a character and its sprite, weapon, and hitbox sit under one parent so transforming the parent transforms the whole unit.

It is also a footgun: scaling a parent to (2, 2) doubles every child — including collision shapes, which silently changes hit detection. When something downstream looks wrong, check whether an ancestor's transform is the real cause.

To move a point between spaces, use the conversion helpers:

var world_point := node.to_global(Vector2(10, 0))   # local → world
var local_point := node.to_local(get_global_mouse_position())  # world → local

Example

A turret that aims at a target, in world space:

func _physics_process(_delta: float) -> void:
    # look_at points the node's +x toward a world point; angles are radians, y-down
    look_at(target.global_position)

look_at works in global coordinates and sets rotation (radians) so the node's local +x faces the target. If you fed it a local point, or treated the result as degrees, the turret would aim at the wrong place — the local/global and radian/degree distinctions in one line.

Walkthrough

  1. Make a Node2D parent at a visible spot, with a Sprite2D child. In the child's script, print position and global_position in _ready. Move the parent in the editor and run again; watch the global differ from the local while the local stays put.
  2. Set the child's rotation_degrees = 45 and observe the tilt. Then set rotation = 45 and run — it spins absurdly, because 45 radians is many turns. Fix back to rotation_degrees or PI/4.
  3. Move "up": from _physics_process, do position += Vector2.UP * 100 * delta and confirm the node travels up the screen (negative y). Then try Vector2(0, 1) and watch it go down — the y-down convention, felt.
  4. Scale the parent to Vector2(2, 2) and observe the child doubling with it. Imagine a collision shape among those children and why this matters.

Optional sanity check

Put the mouse position into local space: in _process, print(to_local(get_global_mouse_position())) and move the cursor. The numbers are relative to this node, not the screen — convert with to_global/to_local whenever a point crosses between world and a node's own space.

Self-check quiz

Q1 — In Godot 2D, which direction is screen-\"up\", and what is Vector2.UP?

A. +y, and Vector2.UP is (0, 1). B. −y, and Vector2.UP is (0, -1), because the y-axis points down. C. +x, and Vector2.UP is (1, 0). D. It depends on the camera.

Reveal answer

B. 2D space is y-down (origin top-left, +y downward), so screen-up is negative y, and the engine's Vector2.UP constant is (0, -1). A uses the math/y-up convention Godot 2D does not follow. C confuses up with the +x (zero-angle) direction. D is false — the convention is fixed, independent of the camera.

Q2 — A child node under a parent at world (200, 0) has position = (10, 5). What is its global_position?

A. (10, 5) B. (210, 5) C. (200, 0) D. Undefined until you call a function.

Reveal answer

B. position is relative to the parent, so the world position is the parent's plus the child's local offset: (200 + 10, 0 + 5) = (210, 5). A reports only the local position; C reports only the parent's; D is wrong — global_position is always defined for a node in the tree.

Q3 — You set node.rotation = 90 expecting a quarter turn, and it spins wildly. Why?

A. rotation is read-only. B. rotation is in radians; 90 radians is about 14 full turns. Use rotation_degrees = 90 or rotation = PI/2. C. Rotation must be set in _process. D. 90 is too large a number for a float.

Reveal answer

B. rotation uses radians, so 90 means 90 radians (~14 turns), not 90 degrees. The fix is rotation_degrees = 90 or rotation = PI/2. A is false (it is writable). C is irrelevant to the unit. D is false — floats hold 90 fine; the issue is the unit, not the magnitude.

Integration question

Q4 — open

A top-down character is built as a Node2D Player with a Sprite2D and a CollisionShape2D child. Bugs: pressing "up" moves the character down; aiming code does rotation = angle_in_degrees and the sprite spins randomly; and after someone set Player.scale = Vector2(1.5, 1.5) to make the art bigger, hit detection became unreliable. Diagnose each using the y-down convention, rotation units, and transform compounding.

Reveal expected answer

Up moves down because the code treats +y as up; Godot 2D is y-down, so screen-up is −y. The fix is to move along Vector2.UP (which is (0, -1)) or negate the y input, rather than adding positive y. The sprite spins randomly because rotation is in radians but the code assigns a degrees value, so each "degree" is interpreted as a radian (~57× too much); the fix is rotation_degrees = angle_in_degrees (or convert with deg_to_rad). Hit detection broke because transforms compound down the tree: scaling Player to 1.5× scaled every child, including the CollisionShape2D, so the collision area changed size along with the art — usually not intended. The fix is to scale only the sprite (or use a larger texture / proper shape) and leave the collision shape at the size the gameplay assumes, keeping the parent's scale at (1, 1). All three trace to the same theme: 2D space has a fixed y-down, radian-based convention, and a parent's transform cascades onto everything beneath it.