Game Development with Godot & GDScript Module 6 – Game math and 2D physics
Vector math for games
Use Godot 4.7 Vector2 for gameplay: distance and direction, dot and cross products for a view cone, turning the short way and frame-rate independent smoothing.
What you will learn
- Answer gameplay questions with length, normalized, direction_to, dot and cross
- Predict which way an angle turns on Godot's y-down axes, and convert degrees to radians
- Rotate towards a target by the short way with rotate_toward, lerp_angle and angle_difference
- Write movement and smoothing that give the same result at any frame rate
Before you start
What you’ll learn · Before you start
What you will learn
- Answer gameplay questions with length, normalized, direction_to, dot and cross
- Predict which way an angle turns on Godot's y-down axes, and convert degrees to radians
- Rotate towards a target by the short way with rotate_toward, lerp_angle and angle_difference
- Write movement and smoothing that give the same result at any frame rate
Before you start
On this page
A guard should notice the player within 300 px and 45 degrees either side of where it faces. That takes four vector operations, and the most tempting way to write them let the guard see a player 56.3 degrees off to its side.
Vectors answer most of the questions a game asks on every tick: how far, which way, in front or behind, left or right,
and how to turn or move towards something without overshooting. This page takes them one at a time with Godot 4.7’s
Vector2: the axes and angles, the offset between two positions, the dot and cross products of the guard’s check,
turning the short way at a capped rate, and smoothing that moves the same at 30 and at 144 frames a second. The
numbers on this page are what Godot 4.7.2 printed for scripts run without a window, and each script is here for you
to run in your own copy of Godot.
Godot’s y axis points down, and its angles are radians
A balloon that should rise up the screen at 200 px a second needs the velocity Vector2(0, -200). In a Godot 2D
scene, x grows to the right and y grows downwards, with the origin at the top-left corner of the screen: the Vector
math page notes that this is the opposite of the way school maths draws the y axis. So Vector2.UP is (0, -1),
which the Vector2 reference explains as “Y is down in 2D, so this vector points -Y”.
Angles follow from the axes. An angle is measured in radian: The unit Godot uses for angles: a full turn is TAU, 2 * PI radians, so 90 degrees is PI / 2, about 1.5708. Node2D.rotation and Vector2.angle() are in radians; deg_to_rad() and rad_to_deg() convert. from Vector2.RIGHT, and
because y points down, a growing angle turns a direction clockwise on the screen. Look at the four directions of a
quarter turn each:
Godot's 2D axes and the four angles of a quarter turn
Text description of the diagram
The figure draws two axes through one origin. The x axis points to the right and the y axis points down the screen, as in every Godot 2D scene: a larger y is lower on the screen. A dashed circle of radius 1 around the origin carries four marked directions, each with the angle that from_angle() turns into it:
- 0 rad, to the right: (1, 0), Vector2.RIGHT.
- PI / 2, down the screen: (0, 1), Vector2.DOWN.
- PI, to the left: (-1, 0), Vector2.LEFT.
- -PI / 2, up the screen: (0, -1), Vector2.UP.
A curved arrow runs from the direction of 0 rad to the direction of PI / 2, labelled "positive: clockwise": because y points down, an angle that grows turns a direction clockwise on the screen, the opposite of the school drawing with y pointing up.
This script prints the four directions, a quarter turn of Vector2.RIGHT, and two comparisons that go wrong:
extends SceneTree
# Godot's 2D axes and angles: x grows to the right and y grows downwards, and an angle is in radians, measured from
# Vector2.RIGHT, so a positive angle turns clockwise on the screen. Vector2 keeps 32-bit floats.
func _initialize() -> void:
print("UP %s, DOWN %s, LEFT %s, RIGHT %s" % [Vector2.UP, Vector2.DOWN, Vector2.LEFT, Vector2.RIGHT])
for degrees: float in [0.0, 90.0, 180.0, -90.0]:
var direction := Vector2.from_angle(deg_to_rad(degrees))
print("%6.1f degrees = %8.5f rad, from_angle() points %s" % [degrees, deg_to_rad(degrees),
direction.snapped(Vector2(0.001, 0.001))])
print("Vector2.RIGHT.rotated(PI / 2) = %s" % Vector2.RIGHT.rotated(PI / 2))
var down := Vector2(0, 1).angle()
print("Vector2(0, 1).angle() = %s, PI / 2 = %s" % [down, PI / 2])
print("rad_to_deg(Vector2(0, 1).angle()) = %s" % rad_to_deg(down))
print("angle == PI / 2: %s; is_equal_approx(angle, PI / 2): %s" % [down == PI / 2, is_equal_approx(down, PI / 2)])
var sum := Vector2(0.1, 0.0) + Vector2(0.2, 0.0)
print("Vector2(0.1, 0) + Vector2(0.2, 0) = %s; its x is %.10f, so x == 0.3 is %s" % [sum, sum.x, sum.x == 0.3])
quit() from session import files, run
files("project.godot", "axes.gd")
run("""
godot --headless --no-header --script axes.gd
""") Output
$ godot --headless --no-header --script axes.gd UP (0.0, -1.0), DOWN (0.0, 1.0), LEFT (-1.0, 0.0), RIGHT (1.0, 0.0) 0.0 degrees = 0.00000 rad, from_angle() points (1.0, 0.0) 90.0 degrees = 1.57080 rad, from_angle() points (0.0, 1.0) 180.0 degrees = 3.14159 rad, from_angle() points (-1.0, 0.0) -90.0 degrees = -1.57080 rad, from_angle() points (0.0, -1.0) Vector2.RIGHT.rotated(PI / 2) = (-0.0, 1.0) Vector2(0, 1).angle() = 1.57079637050629, PI / 2 = 1.5707963267949 rad_to_deg(Vector2(0, 1).angle()) = 90.0000025044782 angle == PI / 2: false; is_equal_approx(angle, PI / 2): true Vector2(0.1, 0) + Vector2(0.2, 0) = (0.3, 0.0); its x is 0.3000000119, so x == 0.3 is false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 axes_session.py
deg_to_rad(90.0) is 1.5708 rad, and Vector2.from_angle() turned it into (0, 1), straight down. A node’s
rotation is in radians too, while rotation_degrees is the same angle in degrees. Rotating Vector2.RIGHT by
PI / 2 gave (-0.0, 1.0): the minus sign on zero is harmless, and it shows that the arithmetic left a tiny negative
number behind.
The last four lines matter more. Vector2(0, 1).angle() printed 1.57079637050629, while PI / 2 is
1.5707963267949, so == said false. The Vector2 reference says why: its components use 32-bit precision, “unlike
float which is always 64-bit”. A vector holds a single-precision float: A 32-bit floating-point number with about 6 reliable digits, used inside Vector2, Vector3 and PackedFloat32Array. It holds every whole number only up to 16,777,216; a float variable is 64-bit. More in “Variables, types and operators” in each
component, so 0.1 + 0.2 came out as 0.3000000119, and a test for 0.3 failed.
Comparing vectors and angles with ==
if direction == Vector2.DOWN: and if angle == PI / 2: can fail for values that went through arithmetic, as the
run’s angle did. Write direction.is_equal_approx(Vector2.DOWN) and is_equal_approx(angle, PI / 2), which the
Vector2 reference recommends because of the rounding errors.
Checkpoint
Check: Godot's 2D axes, radians and 32-bit vectors
Subtract two positions to get the offset between them
The guard stands at (400, 300) and player B at (500, 450). Subtracting the guard’s position from the player’s gives the offset, the arrow from the guard to the player: (100, 150). The Vector math page puts the rule in one line, “To find a vector pointing from A to B, use B - A”, and every question about the pair starts from it.
Two numbers come out of an offset. Its length() is the distance, the square root of 100² + 150², 180.3 px. Its
normalized() copy keeps the direction and has length 1, a unit vector: A vector of length 1, which holds a direction and nothing else. normalized() and direction_to() return one, and the dot product of two unit vectors is the cosine of the angle between them.: (0.555, 0.832).
guard.direction_to(player) gives that direction in one call, and guard.distance_to(player) the distance. Step
through the first half of the guard’s check for player B:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two positions. The guard stands at (400, 300) and faces (1, 0), to the right. Player B stands at (500, 450). Neither position says anything yet about the other.
- Subtract. The player's position minus the guard's gives the offset, the arrow from the guard to the player. (500, 450) - (400, 300) = (100, 150).
- How far. length() is the square root of 100 squared plus 150 squared, 180.3 px. That is inside the guard's range of 300 px, so the check goes on.
- Which way. normalized() divides the offset by its length and keeps its direction, (0.555, 0.832), a vector of length 1. The distance no longer takes part in what follows.
- Ready to compare. A direction of length 1 can be compared with the guard's facing, also of length 1. The dot product and the cross product, in the next section, make that comparison.
The distance passed: 180.3 px is inside the range of 300 px. A check that runs for many guards every tick can skip the
square root. offset.length_squared() > RANGE * RANGE rejects the same players as offset.length() > RANGE, because
both sides are squared, and the Vector2 reference says length_squared() “runs faster than length()”. The guard’s
script uses it.
normalized() has one edge to know. A zero vector has no direction, and the Vector math page says it cannot be
normalized; in GDScript, the Vector2 reference says, normalized() returns (0, 0) when the length is 0, with no
error. A player standing exactly on the guard’s position therefore has the direction (0, 0), and the next section’s
test gives 0 for it.
Checkpoint
Check: Offsets, distances and directions
The dot product says in front, the cross product says which side
The dot product of two vectors multiplies their components and adds them up: a.x * b.x + a.y * b.y. For two unit
vectors it equals the cosine of the angle they make, so it runs from 1 (the same
direction) through 0 (a right angle) to -1 (opposite directions). That makes a view cone one comparison: the player
is inside a cone of 90 degrees when the dot product of the facing and the player’s direction is at least
cos(45°) = 0.707. The guard’s script checks five players this way, then checks them again with the tempting version
that skips normalized():
extends SceneTree
# A guard at (400, 300) faces right. It sees a player who is within 300 px and within 45 degrees of where it faces,
# a view cone of 90 degrees. Five players stand around it. The check takes one subtraction, one length and one dot
# product; one cross product says on which side of the guard a player is.
const GUARD := Vector2(400, 300)
const FACING := Vector2.RIGHT # the guard's rotation is 0
const RANGE := 300.0
const HALF_ANGLE := deg_to_rad(45.0)
const PLAYERS := {
"A": Vector2(600, 260), "B": Vector2(500, 450), "C": Vector2(250, 320), "D": Vector2(800, 330),
"E": Vector2(650, 420),
}
func sees(target: Vector2) -> bool:
var offset := target - GUARD
if offset.length_squared() > RANGE * RANGE:
return false
return FACING.dot(offset.normalized()) >= cos(HALF_ANGLE)
# The tempting version: the dot product of the facing and the raw offset, compared with the same cosine.
func sees_unnormalized(target: Vector2) -> bool:
var offset := target - GUARD
return offset.length() <= RANGE and FACING.dot(offset) >= cos(HALF_ANGLE)
func _initialize() -> void:
print("Guard at (400, 300) facing (1, 0): range 300 px, cone 90 degrees, cos(45 degrees) = %.3f" % cos(HALF_ANGLE))
print("player offset distance angle dot cross side sees unnormalized")
for name: String in PLAYERS:
var target: Vector2 = PLAYERS[name]
var offset := target - GUARD
var cross := FACING.cross(offset)
print("%-7s %-12s %8.1f %6.1f %6.3f %6d %-6s %-5s %s" % [
name, "(%d, %d)" % [offset.x, offset.y], offset.length(), rad_to_deg(FACING.angle_to(offset)),
FACING.dot(offset.normalized()), cross, "right" if cross > 0 else "left", sees(target),
sees_unnormalized(target)])
quit() from session import files, run
files("project.godot", "fov_check.gd")
run("""
godot --headless --no-header --script fov_check.gd
""") Output
$ godot --headless --no-header --script fov_check.gd Guard at (400, 300) facing (1, 0): range 300 px, cone 90 degrees, cos(45 degrees) = 0.707 player offset distance angle dot cross side sees unnormalized A (200, -40) 204.0 -11.3 0.981 -40 left true true B (100, 150) 180.3 56.3 0.555 150 right false true C (-150, 20) 151.3 172.4 -0.991 20 right false false D (400, 30) 401.1 4.3 0.997 30 right false false E (250, 120) 277.3 25.6 0.902 120 right true true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fov_session.py
A and E are seen; B, C and D are not, each for its own reason. B is close but 56.3 degrees off the facing, and its dot product, 0.555, is below 0.707. C is behind, at -0.991. D is almost straight ahead, but 401 px away, so the range check stopped it first. The figure draws the same five players to scale:
The guard's view cone and the five players of the run
Text description of the diagram
A square marks the guard at (400, 300), with an arrow for its facing, (1, 0), to the right. A shaded wedge spreads from it 45 degrees above and 45 degrees below the facing, out to a radius of 300 px: everything the guard can see. Five players are drawn to scale, as filled dots when the guard sees them and as hollow dots when it does not.
- A at (600, 260): inside the wedge, 11.3 degrees off the facing and 204 px away. Seen.
- B at (500, 450): below the wedge, 56.3 degrees off the facing, although only 180 px away. Not seen.
- C at (250, 320): behind the guard, on the far side from the wedge; its dot product with the facing is -0.991. Not seen.
- D at (800, 330): almost straight ahead, 4.3 degrees off, but 401 px away, beyond the wedge's edge. Not seen.
- E at (650, 420): inside the wedge, 25.6 degrees off and 277 px away. Seen.
The last column is the bug from the opening. sees_unnormalized() took the dot product with the raw offset, which
multiplies the cosine by the distance: for B, 180.3 × 0.555 = 100, and 100 is far above 0.707. Without
normalized(), the comparison stops being about angles, and any player more than a pixel or so away who stands in
front of the guard passes.
The cross product answers the other question, left or right. In 2D, Vector2.cross() returns one number,
a.x * b.y - a.y * b.x, which the Vector2 reference calls “the 2D analog of the cross product”. Its sign tells the
side: with y pointing down, a positive value means the second vector is clockwise of the first, which is the right
side of anyone facing along the first. B’s cross product is 1 × 150 - 0 × 100 = 150, so B is on the guard’s right; A’s
is -40, on its left. When the two vectors are parallel the cross product is 0, the reference says, so a player
straight ahead or straight behind gives 0, and the dot product’s sign separates the two.
The model below holds the guard’s facing as a and B’s offset, scaled down to (2, 3), as b. It draws both in one plane,
seen at an angle, with their cross product standing at right angles to it. Its axes are those of maths, not Godot’s
y-down screen, so read its numbers rather than which way its picture turns. The cross product’s z component, 3, is
what Vector2.cross() returns for (1, 0) and (2, 3), the run’s 150 scaled down by 50. Its facts give the angle,
56.3°, and the projection of a on b, 0.555, which is B’s dot product in the run. Type another player’s offset into b,
such as A’s, (200, -40) scaled to (5, -1):
a = (1, 0, 0), b = (2, 3, 0): a · b = 2, a × b = (0, 0, 3).
Key idea
Normalize the offset, then compare: the dot product with the facing against the cosine of half the cone says in front or not, and the sign of the cross product says on which side.
Checkpoint
Check: The dot and cross products
Turn the short way, and no faster than the turn rate
A turret faces 170 degrees and must turn to face -170 degrees. The two angles are 20 degrees apart across the left of the circle, where the numbers jump from 180 to -180, and that seam is where turning code goes wrong. This run turns the turret three ways, one physics tick at a time:
extends SceneTree
# A turret faces 170 degrees and must turn to face -170 degrees: 20 degrees away, across the left of the circle.
# Three ways to turn it, one physics tick (1/60 s) at a time: lerp() on the raw angles, lerp_angle(), and
# rotate_toward() capped at 180 degrees a second.
const START := deg_to_rad(170.0)
const GOAL := deg_to_rad(-170.0)
const TURN_RATE := PI # radians a second: 180 degrees
const DELTA := 1.0 / 60.0
func shown(angle: float) -> String:
return "%.1f" % rad_to_deg(wrapf(angle, -PI, PI))
func _initialize() -> void:
print("From 170 to -170 degrees: angle_difference() = %.1f degrees" % rad_to_deg(angle_difference(START, GOAL)))
print("lerp_angle() from 350 to 10 degrees, halfway: %.1f degrees" % rad_to_deg(
lerp_angle(deg_to_rad(350.0), deg_to_rad(10.0), 0.5)))
var by_lerp := START
var by_lerp_angle := START
var by_rotate_toward := START
print("tick lerp() lerp_angle() rotate_toward()")
for tick in range(1, 13):
by_lerp = lerp(by_lerp, GOAL, 0.2)
by_lerp_angle = lerp_angle(by_lerp_angle, GOAL, 0.2)
by_rotate_toward = rotate_toward(by_rotate_toward, GOAL, TURN_RATE * DELTA)
if tick <= 3 or tick == 7 or tick % 3 == 0:
print("%4d %7s %13s %16s" % [tick, shown(by_lerp), shown(by_lerp_angle), shown(by_rotate_toward)])
print("rotate_toward() stopped at %.4f rad, %.1f degrees; == GOAL: %s; angle_difference() to GOAL: %.1f" % [
by_rotate_toward, rad_to_deg(by_rotate_toward), by_rotate_toward == GOAL,
rad_to_deg(angle_difference(by_rotate_toward, GOAL))])
quit() from session import files, run
# The turret turns three ways; the run prints ticks 1, 2, 3, 6, 7, 9 and 12.
files("project.godot", "turning.gd")
run("""
godot --headless --no-header --script turning.gd
""") Output
$ godot --headless --no-header --script turning.gd From 170 to -170 degrees: angle_difference() = 20.0 degrees lerp_angle() from 350 to 10 degrees, halfway: 360.0 degrees tick lerp() lerp_angle() rotate_toward() 1 102.0 174.0 173.0 2 47.6 177.2 176.0 3 4.1 179.8 179.0 6 -80.9 -175.2 -172.0 7 -98.7 -174.2 -170.0 9 -124.4 -172.7 -170.0 12 -146.6 -171.4 -170.0 rotate_toward() stopped at 3.3161 rad, 190.0 degrees; == GOAL: false; angle_difference() to GOAL: 0.0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 turning_session.py
angle_difference() found the short way: +20 degrees, through 180. Plain lerp() on the two numbers knows nothing
about the seam, so it turned the turret the long way round, through 102, 47.6 and 4.1 degrees, sweeping across the
whole right half of the circle. lerp_angle() “lerps through the shortest path”, the @GlobalScope reference says: it
went through 180, and halfway from 350 to 10 degrees it gave 360, the same direction as 0. But a share of the gap per
tick slows down as it nears the goal, and after 12 ticks it was still 1.4 degrees short.
rotate_toward(from, to, delta) turns by a fixed step, here 180 degrees a second times 1/60 s, 3 degrees a tick,
along the short way, and the reference says it “will not go past” its target. It reached the goal on tick 7 and
stayed there. It stopped at 3.3161 rad, though, which is 190 degrees, so == GOAL said false: 190 and -170 degrees
are one direction under two numbers. Compare directions with is_zero_approx(angle_difference(a, b)).
A cap on the turn has a cost. Turning at its full rate, a moving object flies a circle whose radius is its speed divided by its turn rate: How fast something may turn, in degrees or radians a second. Turning at its full rate, an object moving at a steady speed flies a circle whose radius is the speed divided by the turn rate in radians a second. in radians a second, and a target inside that circle, beside the object, may never be reached. This missile flies at 300 px a second towards a target 150 px to its right:
extends SceneTree
# A missile flies at 300 px a second from (0, 0), heading right, at a target 150 px to its right side, at (0, 150).
# Every tick it turns towards the target with rotate_toward(), no faster than its turn rate, then moves.
# A capped turn has a smallest circle: its radius is speed / turn rate (in radians a second).
const SPEED := 300.0
const DELTA := 1.0 / 60.0
const TARGET := Vector2(0, 150)
func fly(turn_degrees: float) -> void:
var turn_rate := deg_to_rad(turn_degrees)
var position := Vector2.ZERO
var heading := 0.0
var closest := INF
for tick in range(1, 601):
heading = rotate_toward(heading, (TARGET - position).angle(), turn_rate * DELTA)
position += Vector2.from_angle(heading) * SPEED * DELTA
closest = minf(closest, position.distance_to(TARGET))
if position.distance_to(TARGET) < 10.0:
print("%3d degrees a second: circle radius %5.1f px, hit on tick %d" % [turn_degrees, SPEED / turn_rate, tick])
return
print("%3d degrees a second: circle radius %5.1f px, no hit in 600 ticks (10 s), closest %.1f px" % [turn_degrees,
SPEED / turn_rate, closest])
func _initialize() -> void:
for turn_degrees: float in [90.0, 180.0, 270.0, 360.0]:
fly(turn_degrees)
quit() from session import files, run
files("project.godot", "homing.gd")
run("""
godot --headless --no-header --script homing.gd
""") Output
$ godot --headless --no-header --script homing.gd 90 degrees a second: circle radius 191.0 px, no hit in 600 ticks (10 s), closest 149.9 px 180 degrees a second: circle radius 95.5 px, no hit in 600 ticks (10 s), closest 40.9 px 270 degrees a second: circle radius 63.7 px, hit on tick 40 360 degrees a second: circle radius 47.7 px, hit on tick 36
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 homing_session.py
At 180 degrees a second the circle has a radius of 300 / PI = 95.5 px. The target sat inside it, and the missile circled for 10 seconds, never closer than 40.9 px; at 90 degrees a second its closest was 149.9 px, hardly nearer than the 150 px it started from. At 270 degrees a second the circle shrank to 63.7 px, and the missile hit on tick 40:
A capped turn flies a circle, and a target inside it is never reached
Text description of the diagram
A missile starts at (0, 0), heading right at 300 px a second. Its target is a ringed point at (0, 150), 150 px away on the missile's right side, down the screen. Every 1/60 s the missile turns towards the target by at most its turn rate, then moves.
- The solid line is a turn rate of 270 degrees a second. The missile curls round to the right, straightens out once it points at the target, and reaches it on tick 40.
- The dashed line is a turn rate of 180 degrees a second. The missile turns as hard as it can on every tick, so it flies a circle of radius 300 / PI, 95.5 px. The target lies inside that circle, and the missile passes it again and again, never closer than 40.9 px.
A note under the legend gives the rule: the smallest circle a capped turn can fly has a radius of the speed divided by the turn rate in radians a second.
Size the turn rate from the speed
Choose the turn rate so that the radius, speed ÷ turn rate, is smaller than the closest a target is likely to be, or slow the missile while its target is near and to the side.
Checkpoint
Check: Turning the short way at a capped rate
Move at a speed, or smooth by a share that respects delta
A camera that follows the player, a health bar that drains, a door that slides shut: each moves a value towards a
target on every frame. move_toward(from, to, delta) moves it by a fixed amount and, as the @GlobalScope reference
says, “will not go past” the target. lerp(from, to, weight) moves it by a share of what is left, which starts fast
and slows down as the gap closes. The Interpolation page warns about the second one in its own words: with a weight
per frame, “Despite using delta, the formula used above is framerate-dependent”, because the weight is a share of the
remaining difference, not an amount.
This script runs three followers for half a second of frames at 30, 60 and 144 frames a second, with the delta
that _process() would receive at each rate. One takes 0.1 of the gap each frame, one takes a share of
1.0 - exp(-K * delta), and one moves at 150 px a second:
extends SceneTree
# Three followers chase a target that jumped from x = 0 to x = 100, for half a second of frames at 30, 60 and 144
# frames a second, with delta = 1 / fps as _process() receives it:
# lerp() by a fixed weight of 0.1 each frame,
# lerp() by a weight of 1 - exp(-K * delta), the frame-rate independent form,
# move_toward() at 150 px a second.
# K = 6.3216 makes the second follower take the same 0.1 of the gap each frame at 60 frames a second.
const TARGET := 100.0
const K := 6.3216
func _initialize() -> void:
print("1 - exp(-K / 60) = %.4f; 1 - exp(-10 * 0.016) = %.4f" % [1.0 - exp(-K / 60.0), 1.0 - exp(-10.0 * 0.016)])
print("fps frames lerp 0.1 exp decay move_toward")
for fps: int in [30, 60, 144]:
var delta := 1.0 / fps
var fixed_weight := 0.0
var decay := 0.0
var steady := 0.0
for _frame in fps / 2:
fixed_weight = lerp(fixed_weight, TARGET, 0.1)
decay = lerp(decay, TARGET, 1.0 - exp(-K * delta))
steady = move_toward(steady, TARGET, 150.0 * delta)
print("%3d %7d %9.2f %10.2f %12.2f" % [fps, fps / 2, fixed_weight, decay, steady])
quit() from session import files, run
files("project.godot", "smoothing.gd")
run("""
godot --headless --no-header --script smoothing.gd
""") Output
$ godot --headless --no-header --script smoothing.gd 1 - exp(-K / 60) = 0.1000; 1 - exp(-10 * 0.016) = 0.1479 fps frames lerp 0.1 exp decay move_toward 30 15 79.41 95.76 75.00 60 30 95.76 95.76 75.00 144 72 99.95 95.76 75.00
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 smoothing_session.py
The same table, read across the frame rates:
| Follower | 30 frames a second | 60 frames a second | 144 frames a second |
|---|---|---|---|
lerp() by 0.1 a frame |
79.41 | 95.76 | 99.95 |
lerp() by 1 - exp(-K * delta) |
95.76 | 95.76 | 95.76 |
move_toward() at 150 px a second |
75.00 | 75.00 | 75.00 |
The fixed share felt right at 60 frames a second and wrong everywhere else: on a 144 Hz screen the camera snapped
almost all the way in half a second, and at 30 frames a second it lagged. The other two followers ignored the frame
rate. move_toward() multiplies a speed by delta, so the frames always add up to the same distance. The weight
1 - exp(-K * delta), which the Interpolation page gives as its frame-rate independent form, takes the right share for
any length of frame: K = 6.3216 gives 0.1000 at 60 frames a second, and the curve is the same at every rate. With
K = 10 and a frame of 0.016 s the weight is 0.1479, so one frame closes 14.79 of a gap of 100. This
exponential smoothing: Moving a value towards a target by a share of 1 - exp(-k * delta) of the remaining gap on every frame. It eases in like a fixed lerp() weight, but gives the same motion at any frame rate. keeps lerp’s easing and loses its dependence on the
frame rate.
Key idea
A change that must take the same time at every frame rate is either an amount times delta, as with
move_toward(), or a share of 1 - exp(-k * delta), never a fixed share per frame.
Checkpoint
Check: Moving and smoothing that respect delta
Practise: aiming helpers for a guard and its turret
Write the three helpers behind a guard: its view cone, its turret’s capped turn, and the smoothing of its aim.
aim.gd has three functions to finish. in_view_cone() tells whether a target is within range and within half of a
cone of cone_degrees either side of a facing angle; turn_toward() turns an angle towards another by at most
max_step, the short way, never past it; smooth_toward() smooths a value towards a target so that a second of
frames gives the same value at any frame rate.
- In a Godot 4.7 project, save
aim.gdandcheck_aim.gdfrom below. - Finish the three functions without changing their names or parameters.
- In a terminal in the project’s folder, run
godot --headless --no-header --script check_aim.gd.
extends RefCounted
# The aiming helpers of a guard and its turret. Finish the three functions; check_aim.gd checks them.
# Angles are in radians, measured from Vector2.RIGHT, positive clockwise on the screen.
## Whether a guard at guard_position, facing the angle facing, sees target: no more than max_range pixels away, and no
## more than half of cone_degrees from the facing on either side.
func in_view_cone(guard_position: Vector2, facing: float, target: Vector2, cone_degrees: float, max_range: float) -> bool:
return false
## The angle after turning from angle towards target_angle by at most max_step, the short way round, never past it.
func turn_toward(angle: float, target_angle: float, max_step: float) -> float:
return angle
## The value after one frame of delta seconds of smoothing from value towards target at rate (a share per second),
## so that a second of frames gives the same value at any frame rate.
func smooth_toward(value: float, target: float, rate: float, delta: float) -> float:
return value extends SceneTree
# Checks aim.gd of the practice. Run it in the project's folder:
# godot --headless --no-header --script check_aim.gd
# It asks the three helpers about the guard of the lesson, a guard that faces down, turns across the
# -180/180 degree seam, and smooths for half a second at 30 and at 144 frames a second.
const GUARD := Vector2(400, 300)
var aim: RefCounted
var passed := 0
var total := 0
func _initialize() -> void:
aim = load("res://aim.gd").new()
check(aim.in_view_cone(GUARD, 0.0, Vector2(600, 260), 90.0, 300.0),
"a player 11.3 degrees off the facing, 204 px away, is seen")
check(not aim.in_view_cone(GUARD, 0.0, Vector2(500, 450), 90.0, 300.0),
"a player 56.3 degrees off the facing is outside a 90-degree cone")
check(not aim.in_view_cone(GUARD, 0.0, Vector2(250, 320), 90.0, 300.0), "a player behind the guard is not seen")
check(not aim.in_view_cone(GUARD, 0.0, Vector2(800, 330), 90.0, 300.0), "a player 401 px away is not seen")
check(aim.in_view_cone(GUARD, PI / 2, Vector2(380, 500), 60.0, 300.0) and
not aim.in_view_cone(GUARD, PI / 2, Vector2(600, 260), 60.0, 300.0),
"a guard facing down sees a player below it, and not one on its left")
var angle: float = aim.turn_toward(deg_to_rad(10.0), deg_to_rad(40.0), deg_to_rad(5.0))
check(is_zero_approx(angle_difference(angle, deg_to_rad(15.0))),
"from 10 towards 40 degrees, at most 5: %.1f degrees" % rad_to_deg(angle))
angle = aim.turn_toward(deg_to_rad(170.0), deg_to_rad(-170.0), deg_to_rad(5.0))
check(is_zero_approx(angle_difference(angle, deg_to_rad(175.0))),
"from 170 towards -170 degrees, at most 5, through 180: %.1f degrees" % rad_to_deg(wrapf(angle, -PI, PI)))
angle = aim.turn_toward(deg_to_rad(10.0), deg_to_rad(12.0), deg_to_rad(5.0))
check(is_zero_approx(angle_difference(angle, deg_to_rad(12.0))),
"from 10 towards 12 degrees, at most 5, stops on 12: %.1f degrees" % rad_to_deg(angle))
var one_step: float = aim.smooth_toward(0.0, 100.0, 10.0, 0.016)
check(absf(one_step - 14.7856) < 0.001, "one frame of 0.016 s at rate 10, from 0 to 100: %.4f" % one_step)
var at_30 := half_a_second(30)
var at_144 := half_a_second(144)
check(absf(at_30 - at_144) < 0.01 and absf(at_30 - 63.21) < 0.01,
"half a second at rate 2, at 30 and at 144 frames a second: %.2f and %.2f" % [at_30, at_144])
print("%d of %d checks passed" % [passed, total])
quit(0 if passed == total else 1)
func half_a_second(fps: int) -> float:
var value := 0.0
for _frame in fps / 2:
value = aim.smooth_toward(value, 100.0, 2.0, 1.0 / fps)
return value
func check(ok: bool, what: String) -> void:
total += 1
passed += int(ok)
print("%s %s" % ["PASS" if ok else "FAIL", what]) The checker asks about the guard of this page, a guard facing down, a turn across the seam at 180 degrees, and half a
second of smoothing at 30 and at 144 frames a second. On a finished aim.gd, and on a tempting one that turns with
move_toward() and smooths by rate * delta:
from session import files, run
# The checker on a finished aim.gd, then on a tempting one that turns with move_toward() and smooths by rate * delta.
files("practice/project.godot=right/project.godot", "practice/check_aim.gd=right/check_aim.gd",
"practice/solution/aim.gd=right/aim.gd",
"practice/project.godot=tempting/project.godot", "practice/check_aim.gd=tempting/check_aim.gd",
"practice/tempting/aim.gd=tempting/aim.gd")
run("""
cd right
godot --headless --no-header --script check_aim.gd
cd ../tempting
!godot --headless --no-header --script check_aim.gd
""") Output
$ cd right $ godot --headless --no-header --script check_aim.gd PASS a player 11.3 degrees off the facing, 204 px away, is seen PASS a player 56.3 degrees off the facing is outside a 90-degree cone PASS a player behind the guard is not seen PASS a player 401 px away is not seen PASS a guard facing down sees a player below it, and not one on its left PASS from 10 towards 40 degrees, at most 5: 15.0 degrees PASS from 170 towards -170 degrees, at most 5, through 180: 175.0 degrees PASS from 10 towards 12 degrees, at most 5, stops on 12: 12.0 degrees PASS one frame of 0.016 s at rate 10, from 0 to 100: 14.7856 PASS half a second at rate 2, at 30 and at 144 frames a second: 63.21 and 63.21 10 of 10 checks passed $ cd ../tempting $ godot --headless --no-header --script check_aim.gd PASS a player 11.3 degrees off the facing, 204 px away, is seen PASS a player 56.3 degrees off the facing is outside a 90-degree cone PASS a player behind the guard is not seen PASS a player 401 px away is not seen PASS a guard facing down sees a player below it, and not one on its left PASS from 10 towards 40 degrees, at most 5: 15.0 degrees FAIL from 170 towards -170 degrees, at most 5, through 180: 165.0 degrees PASS from 10 towards 12 degrees, at most 5, stops on 12: 12.0 degrees FAIL one frame of 0.016 s at rate 10, from 0 to 100: 16.0000 FAIL half a second at rate 2, at 30 and at 144 frames a second: 64.47 and 63.47 7 of 10 checks passed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 check_session.py
The tempting version passed seven checks. Its move_toward() turned the turret from 170 to 165 degrees, the long way,
and its smoothing reached 64.47 at 30 frames a second against 63.47 at 144. When all 10 checks pass, the questions
below test the same five ideas; they begin wherever your answers are weakest and say why each choice is right or
wrong.
Practice that adapts to you
Practise this lesson
15 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up: what does the dot product of a guard’s facing and the direction to the player tell you?
With both vectors normalized, the dot product equals the cosine of the angle they make: 1 when the player is straight ahead, 0 at a right angle and -1 straight behind. A positive value means the player is in front of the guard, and a view cone is one comparison with the cosine of half its angle: at least 0.707 for 90 degrees in total, 0.5 for 120. In a Godot 4.7.2 run, a player 56.3 degrees off the facing gave 0.555 and stayed unseen in a 90-degree cone. Normalize first: with the raw offset the dot product is also multiplied by the distance, and the same player gave 100. For left or right, use the sign of the cross product instead.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
Playtesters say stealth guards notice them from far off to the side, almost behind, although guards should see only 90 degrees ahead. The check is facing.dot(player.global_position - global_position) > 0.707. What is wrong, how do you fix it, and how do you keep the check cheap for 200 guards?
-
Fresher to mid
A camera follows the player with position = position.lerp(target, 0.1) in _process(). On 144 Hz monitors players call it twitchy, and on a slow laptop it lags behind. Explain why, fix it so that it feels the same everywhere, and say how you would test the fix.
Key takeaways
- In Godot 2D, y grows downwards, so
Vector2.UPis(0, -1), angles are radians fromVector2.RIGHT, and a positive angle turns clockwise on the screen; vectors keep 32-bit floats, so they are compared withis_equal_approx(). - The offset from a to b is
b - a; itslength()is the distance, itsnormalized()copy the direction, andlength_squared()compares distances without a square root. - Between unit vectors, the dot product equals the cosine of their angle, which turns a view cone into one comparison, and the sign of the cross product says on which side the second vector lies.
rotate_toward()andangle_difference()turn and compare by the short way across the 180-degree seam, and a capped turn flies a circle of radius speed ÷ turn rate.move_toward()with a speed timesdelta, andlerp()with a weight of1 - exp(-k * delta), move a value the same way at every frame rate; a fixed weight per frame does not.
Terms in this lesson
- exponential smoothing
- Moving a value towards a target by a share of 1 - exp(-k * delta) of the remaining gap on every frame. It eases in like a fixed lerp() weight, but gives the same motion at any frame rate.
- radian
- The unit Godot uses for angles: a full turn is TAU, 2 * PI radians, so 90 degrees is PI / 2, about 1.5708. Node2D.rotation and Vector2.angle() are in radians; deg_to_rad() and rad_to_deg() convert.
- turn rate
- How fast something may turn, in degrees or radians a second. Turning at its full rate, an object moving at a steady speed flies a circle whose radius is the speed divided by the turn rate in radians a second.
- unit vector
- A vector of length 1, which holds a direction and nothing else. normalized() and direction_to() return one, and the dot product of two unit vectors is the cosine of the angle between them.
References
- Vector math (Godot Engine)
- Advanced vector math (Godot Engine)
- Vector2 (class reference) (Godot Engine)
- @GlobalScope (class reference): lerp_angle, angle_difference, rotate_toward, move_toward (Godot Engine)
- Interpolation: smoothing motion (Godot Engine)
- Matrices and transforms: the y axis in 2D (Godot Engine)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress