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) versusglobal_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_positionwhen you meantposition(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.UPis(0, -1)(L1.6). - Angles are measured in radians, increasing clockwise (a side effect of y pointing down). An
angle of
0points 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¶
-
rotationis in radians;rotation_degreesis the same rotation in degrees. Set whichever is convenient, but do not assign a degree number torotation: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 turnsConvert with
deg_to_rad/rad_to_deg(L1.6) when you have one and need the other. -
scaleis aVector2multiplier (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¶
- Make a
Node2Dparent at a visible spot, with aSprite2Dchild. In the child's script, printpositionandglobal_positionin_ready. Move the parent in the editor and run again; watch the global differ from the local while the local stays put. - Set the child's
rotation_degrees = 45and observe the tilt. Then setrotation = 45and run — it spins absurdly, because 45 radians is many turns. Fix back torotation_degreesorPI/4. - Move "up": from
_physics_process, doposition += Vector2.UP * 100 * deltaand confirm the node travels up the screen (negative y). Then tryVector2(0, 1)and watch it go down — the y-down convention, felt. - 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.