Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with godot 4.7.2-stable and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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: x to the right, y downwards; angles 0, PI / 2, PI and -PI / 2 on a circle, positive angles turning clockwise.Godot's 2D axes and anglesxy grows downwardspositive:clockwise0 rad: (1, 0)PI / 2: (0, 1)PI: (-1, 0)-PI / 2: UP (0, -1)Angles are radians from Vector2.RIGHT; apositive one turns clockwise on the screen.

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:

  1. 0 rad, to the right: (1, 0), Vector2.RIGHT.
  2. PI / 2, down the screen: (0, 1), Vector2.DOWN.
  3. PI, to the left: (-1, 0), Vector2.LEFT.
  4. -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:

axes.gd: Godot's 2D axes and angles GD · axes.gd
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()
Axes, angles and 32-bit vectors, Godot 4.7.2 Python · axes_session.py
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.

Unit Circle Chart & Calculator See the cosine and sine of any angle in radians, the x and y that Vector2.from_angle() returns.

Checkpoint

Check: Godot's 2D axes, radians and 32-bit vectors

  1. Question 1 of 2 A balloon should rise straight up the screen at 200 px a second. Which velocity does that?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Vector2(0, -200)

    Vector2.UP is (0, -1) because the y axis points down in 2D, as the run printed. Up the screen is always a negative y.

  2. Question 2 of 2 A script tests if Vector2(0, 1).angle() == PI / 2: and the block never runs. What is the cause, and the fix?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Vector2 keeps 32-bit floats, so the angle is 1.57079637050629, not PI / 2; test with is_equal_approx()

    Vector2 stores 32-bit floats while a float variable is 64-bit, so a value that went through a vector rarely matches a constant to the last digit. Compare with is_equal_approx(), or with a tolerance of your own.

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:

Interactive · step by step The guard's check for player B, first half Step through the subtraction, the distance and the direction. The sub-labels keep the run's numbers.
The guard's check for player B, first half, step 5 of 5. Showing Guard, Player B, Offset ((100, 150)), Distance (180.3 px, within 300), Direction ((0.555, 0.832)), Next. In focus: Next.minus guardGuard(400, 300), faces (1, 0)Player Bat (500, 450)Offset(100, 150)Distance180.3 px, within 300Direction(0.555, 0.832)Nextdot and cross products

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 A guard stands at (100, 100) and a player at (400, 500). How far apart are they?

    Type a number.

    A hint

    Subtract first, then take the length of the offset.

    Show the answer: Question 1 of 2

    Answer: 500 px

    The offset is (400, 500) - (100, 100) = (300, 400), and its length is the square root of 300² + 400² = 250,000, which is 500 px. guard.distance_to(player) gives the same number in one call.

  2. Question 2 of 2 A check runs for 200 guards every tick and must tell whether the player is within 300 px. Which test does it without a square root?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: offset.length_squared() <= 300 * 300

    Compare squared lengths with a squared range. The fov_check.gd run uses offset.length_squared() > RANGE * RANGE to reject players who are too far away.

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():

fov_check.gd: a view cone of 90 degrees and 300 px GD · fov_check.gd
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()
Five players around one guard, Godot 4.7.2 Python · fov_session.py
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:

A guard facing right with a shaded 90-degree view cone of 300 px; players A and E inside it, B, C and D outside.The guard at (400, 300) and five playersguardABCDEfacing (1, 0)Aseen: 11.3 degrees off, 204 pxBnot seen: 56.3 degrees offCnot seen: behind, dot -0.991Dnot seen: 401 px awayEseen: 25.6 degrees off, 277 pxseennot seen; shaded: the 90-degree cone

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.

  1. A at (600, 260): inside the wedge, 11.3 degrees off the facing and 204 px away. Seen.
  2. B at (500, 450): below the wedge, 56.3 degrees off the facing, although only 180 px away. Not seen.
  3. 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.
  4. D at (800, 330): almost straight ahead, 4.3 degrees off, but 401 px away, beyond the wedge's edge. Not seen.
  5. 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):

Interactive · in 3D The guard's facing and player B's offset Set the coordinates of a and b. The dot product measures how much they point the same way; the cross product is perpendicular to both, as long as the area of the parallelogram they span.
Vectors a = (1, 0, 0) and b = (2, 3, 0) on x, y and z axes, with a × b.xyzaba × b (half length)

a = (1, 0, 0), b = (2, 3, 0): a · b = 2, a × b = (0, 0, 3).

a · b 2 · aₓbₓ + a_yb_y + a_zb_z
Angle between a and b 56.3° · cos θ = a · b ÷ (|a| |b|)
a × b (0, 0, 3) · (a_yb_z − a_zb_y, a_zb_x − a_xb_z, a_xb_y − a_yb_x)
|a × b| (parallelogram area) 3
Projection of a on b 0.555 · a · b ÷ |b|

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

  1. Question 1 of 2 A guard should see 120 degrees in total, 60 on each side of its facing. With a normalized offset, which test is right?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: facing.dot(offset.normalized()) >= cos(deg_to_rad(60.0)), that is 0.5

    Compare the dot product of two unit vectors with the cosine of the half-angle: cos(45°) = 0.707 for the run's 90-degree cone, cos(60°) = 0.5 for a 120-degree one.

  2. Question 2 of 2 A guard faces up the screen, Vector2.UP. facing.cross(offset) for a player gives -80. Where is the player?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: On the guard's left

    With y pointing down, a positive cross product means the offset is clockwise of the facing (to the right), and a negative one means counterclockwise (to the left), whichever way the guard faces.

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:

turning.gd: three ways to turn a turret GD · turning.gd
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 170 to -170 degrees, Godot 4.7.2 Python · turning_session.py
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:

homing.gd: a missile with a capped turn GD · homing.gd
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()
Four turn rates, one target beside the missile, Godot 4.7.2 Python · homing_session.py
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:

Two missile paths from (0, 0): at 270 degrees a second it curls into the target at (0, 150); at 180 it circles past.A missile at 300 px a secondturning towards a target 150 px asidestart (0, 0)target (0, 150), to the right side270 degrees a second: hit on tick 40180 a second: circles, 40.9 px offradius = speed / turn rate: 95.5 px at 180

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.

  1. 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.
  2. 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

  1. Question 1 of 2 A turret at 170 degrees turns towards -170 degrees with rotate_toward() and a step of 3 degrees a tick, as in the run. On which tick does it face the goal?

    Type a number.

    A hint

    angle_difference() says how far the short way is.

    Show the answer: Question 1 of 2

    Answer: 7 ticks

    The short way is 20 degrees. Six steps of 3 degrees leave 2 degrees (the run printed -172.0 on tick 6), and the seventh step stops on the goal, because rotate_toward() never goes past it.

  2. Question 2 of 2 A missile flies at 400 px a second and turns at most 180 degrees a second. What is the radius of the smallest circle it can fly?

    Type a number.

    A hint

    Use the turn rate in radians a second.

    Show the answer: Question 2 of 2

    Answer: 127.3 px (anything from 126.8 to 127.8 counts)

    180 degrees a second is PI radians a second, and the radius is speed / turn rate = 400 / PI = 127.3 px. A target inside that circle, beside the missile, can make it circle as the run's 180-degree missile did.

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:

smoothing.gd: three followers at three frame rates GD · smoothing.gd
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()
Half a second at 30, 60 and 144 frames a second, Godot 4.7.2 Python · smoothing_session.py
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

  1. Question 1 of 2 A camera runs position = position.lerp(target, 0.1) in _process(). How does it behave at 144 frames a second compared with 60?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It catches up faster, because it takes 0.1 of the gap more often

    A fixed weight per frame makes the motion depend on the frame rate; the weight 1 - exp(-k * delta) does not, which is why the run's second follower reached 95.76 at all three rates.

  2. Question 2 of 2 With k = 10 and a frame of delta = 0.016 s, what is the weight 1 - exp(-k * delta)? Give four decimals.

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 0.1479 (anything from 0.1474 to 0.1484 counts)

    exp(-0.16) is 0.8521, so the weight is 0.1479: one frame takes 14.79 % of the gap, 14.7856 of a gap of 100, as the checker of the practice expects.

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.

  1. In a Godot 4.7 project, save aim.gd and check_aim.gd from below.
  2. Finish the three functions without changing their names or parameters.
  3. In a terminal in the project’s folder, run godot --headless --no-header --script check_aim.gd.
aim.gd: the starter GD · practice/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
check_aim.gd: the checker of this task GD · practice/check_aim.gd
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:

The checker on a finished aim.gd and on a tempting one, Godot 4.7.2 Python · check_session.py
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.

  1. Question 1 of 15 What is Vector2.UP in Godot 2D?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: (0, -1)

    The Vector2 reference: "Y is down in 2D, so this vector points -Y."

  2. Question 2 of 15 A designer sets rotation = 90 on a Sprite2D, meaning a quarter turn. Which way does the sprite face?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: 116.6 degrees, because 90 radians is 14 full turns and 116.6 degrees more

    Use deg_to_rad(90.0), PI / 2 or the rotation_degrees property; the axes example converts each of its angles with deg_to_rad().

  3. Question 3 of 15 Two Vector2 positions are computed in different ways and should be the same point. How should a test compare them?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: a.is_equal_approx(b)

    For a looser test, compare a.distance_to(b) with a tolerance that suits the game, such as half a pixel.

  4. Question 4 of 15 Which expression is the vector from the guard to the player?

    Choose one answer.

    Show the answer: Question 4 of 15

    Answer: player.position - guard.position

    Target minus start. guard.position.direction_to(player.position) gives the same direction with length 1.

  5. Question 5 of 15 A player stands exactly on the guard's position, so the offset is (0, 0). What does offset.normalized() return in GDScript?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: (0, 0), with no error

    The dot product of (0, 0) with any facing is 0, so the run's check would call such a player unseen in any cone narrower than 180 degrees; test the distance first if touching should count as seen.

  6. Question 6 of 15 What does a.direction_to(b) return?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: (b - a).normalized(), the direction from a to b with length 1

    Use direction_to() for directions and distance_to() or distance_squared_to() for distances.

  7. Question 7 of 15 For two vectors of length 1, what does a dot product of 0 mean?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: They are at a right angle

    1 means the same direction, 0 a right angle, -1 opposite directions; in between it is the cosine of the angle.

  8. Question 8 of 15 In the run, sees_unnormalized() saw player B, 56.3 degrees off the facing, through a 90-degree cone. Why?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: It took the dot product with the raw offset, 100, and compared it with 0.707

    Normalize the offset (or use direction_to()) before comparing the dot product with a cosine.

  9. Question 9 of 15 facing.cross(offset) is 0 for a player. What can the guard conclude?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: The player is straight ahead or straight behind, and the dot product tells which

    Read the two products together: the cross product for left or right, the dot product for in front or behind.

  10. Question 10 of 15 What does lerp_angle(deg_to_rad(350.0), deg_to_rad(10.0), 0.5) return, in degrees?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: 360, the same direction as 0, halfway along the short way

    The Godot reference: lerp_angle() lerps through the shortest path between the two angles.

  11. Question 11 of 15 After rotate_toward() had turned the run's turret onto its goal of -170 degrees, rotation == GOAL was false. Why?

    Choose one answer.

    Show the answer: Question 11 of 15

    Answer: It ended at 190 degrees, 3.3161 rad, the same direction under another number

    Compare directions with is_zero_approx(angle_difference(a, b)), never with ==.

  12. Question 12 of 15 A homing missile at 300 px a second with a turn rate of 180 degrees a second circles a target forever. Which change makes it hit?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: Raise the turn rate, or slow the missile when the target is close, so that the turning circle shrinks

    A capped turn has a smallest circle of radius speed / turn rate; a target inside that circle, beside the missile, cannot be reached without a tighter turn or a lower speed.

  13. Question 13 of 15 In the run, move_toward(x, 100, 150 * delta) moved x from 0 for half a second. Where was x at 30, 60 and 144 frames a second?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: 75 at all three

    move_toward() never goes past its target, and an amount times delta covers the same distance at any frame rate.

  14. Question 14 of 15 The Interpolation page's first follower uses lerp(a, b, delta * FOLLOW_SPEED). Why does the page call it frame-rate dependent despite the delta?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: The weight is a share of what is left, so many small shares and a few large ones do not add up to the same thing

    The page gives 1 - exp(-FOLLOW_SPEED * delta) as the frame-rate independent weight, the form the run's second follower used.

  15. Question 15 of 15 A follower used lerp(x, target, 0.1) per frame and felt right at 60 frames a second. Which k keeps that feel at every frame rate with the weight 1 - exp(-k * delta)?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: 6.32, from 1 - exp(-k / 60) = 0.1

    Solve 1 - exp(-k * delta) = w for the frame rate where the old weight w felt right: k = -ln(1 - w) / delta.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • In Godot 2D, y grows downwards, so Vector2.UP is (0, -1), angles are radians from Vector2.RIGHT, and a positive angle turns clockwise on the screen; vectors keep 32-bit floats, so they are compared with is_equal_approx().
  • The offset from a to b is b - a; its length() is the distance, its normalized() copy the direction, and length_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() and angle_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 times delta, and lerp() with a weight of 1 - 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.