Why does predicting a CharacterBody2D future path vs actual movement not match?

Godot Version

4.5.1.stable

Question

Hi, I’m trying to predict the future movement path of a CharacterBody2D that behaves like a bouncing projectile.
The idea is to calculate the reflection path (like a ray of light bouncing off walls) before actually moving the projectile with move_and_collide().

To predict the path, I use PhysicsServer2D.body_test_motion() with the same CircleShape2D, same collision layers/masks, same safe margin, and I compute the future bounces step by step, storing the positions and normals to draw a preview line.

Then, during real movement, the projectile actually moves using move_and_collide() and reflects the velocity with .bounce(collision.get_normal()).

The problem is: the predicted path does not match the real path, especially after several bounces.
Usually after around the 8th collision the projectile deviates from the predicted trajectory if the previous bounces were not perfectly orthogonal. The path prediction starts correct but gradually drifts away.

From debugging, it looks like move_and_collide() reports the contact point on the edge of the shape, while body_test_motion() gives me a hit position related to the center of the shape. That means my predicted hit positions and real hit positions diverge.

Exemple

CODE

extends CharacterBody2D

enum State { READY, MOVING }
var current_state = State.READY

var speed := 600.0
const MAX_TRAVEL_DISTANCE := 9000.0
var distance_traveled := 0.0
var bauce_acontecidos : int = 0

Simulação e Caminho

const MAX_ERRO_DISTANCIA_COLISAO = 5.0
const MAX_ERRO_ANGULO_COLISAO = 5.0
const MAX_BOUNCES = 10

var path_colisao_pos: Array[Vector2] = 

var path_colisao_normal: Array[Vector2] = 


var current_path_index: int = 0
var seguindo_o_path: bool = true

@onready var collision_shape_2d: CollisionShape2D = $CollisionShape2D

Nodes

@onready var bullet_2: Sprite2D = $Bullet2

func generate_path(initial_dir: Vector2, max_dist_to_travel: float, max_bounce: int) → void:
path_colisao_pos.clear()
path_colisao_normal.clear()

var motion_remaining := initial_dir * max_dist_to_travel
var current_transform := global_transform
var bounces_done := 0

path_colisao_pos.append(current_transform.origin)
path_colisao_normal.append(Vector2.ZERO)

var params := PhysicsTestMotionParameters2D.new()
params.exclude_bodies = [self.get_rid()]
var result := PhysicsTestMotionResult2D.new()

var bullet_collision_rid = PhysicsServer2D.body_create()
PhysicsServer2D.body_set_space(bullet_collision_rid, get_world_2d().space)
PhysicsServer2D.body_add_shape(bullet_collision_rid, collision_shape_2d.shape)
PhysicsServer2D.body_set_mode(bullet_collision_rid, PhysicsServer2D.BODY_MODE_KINEMATIC)
PhysicsServer2D.body_set_collision_layer(bullet_collision_rid, collision_layer)
PhysicsServer2D.body_set_collision_mask(bullet_collision_rid, collision_mask)
while motion_remaining.length() > 1.0 and bounces_done < max_bounce:
	params.from = current_transform
	params.motion = motion_remaining
	var hit_something := PhysicsServer2D.body_test_motion(bullet_collision_rid, params, result)

	if hit_something:

		var minha_pos = current_transform.origin + result.get_travel()
		var hit_normal = result.get_collision_normal()
		var hit_pos = result.get_collision_point()

		motion_remaining = result.get_remainder().bounce(hit_normal)
		current_transform.origin = minha_pos + motion_remaining.normalized() * 0.1

		path_colisao_pos.append(hit_pos)
		path_colisao_normal.append(hit_normal)
		bounces_done += 1
	else:
		var end_pos = current_transform.origin + motion_remaining
		path_colisao_pos.append(end_pos)
		path_colisao_normal.append(Vector2.ZERO)
		break
PhysicsServer2D.free_rid(bullet_collision_rid)

func comecar_navegacao(aim_direction : Vector2)-> void:
current_path_index = 0
current_state = State.MOVING
distance_traveled = 0.0
seguindo_o_path = true
velocity = aim_direction * speed
bullet_2.rotation = velocity.angle()
bauce_acontecidos = 0

func _physics_process(delta: float) → void:
match current_state:
State.READY:
var aim_direction = global_position.direction_to(get_global_mouse_position())
generate_path(aim_direction, MAX_TRAVEL_DISTANCE, MAX_BOUNCES)
if Input.is_action_just_released(“click”):
comecar_navegacao(aim_direction)

	State.MOVING:
		var initial_pos = global_position
		var collision := move_and_collide(velocity * delta)
		distance_traveled += initial_pos.distance_to(global_position)

		if collision:
			handle_collision_correction(collision)
			bauce_acontecidos += 1

		# Condição de Parada (por distância ou fim do caminho)
		if distance_traveled >= MAX_TRAVEL_DISTANCE or bauce_acontecidos >= MAX_BOUNCES:
			current_state = State.READY

queue_redraw()

func handle_collision_correction(collision: KinematicCollision2D) → void:
var next_path_index = current_path_index + 1
if next_path_index + 1 < path_colisao_pos.size() and seguindo_o_path:
var real_hit_pos: Vector2 = collision.get_position()
var real_hit_normal: Vector2 = collision.get_normal()

	var predicted_hit_pos: Vector2 = path_colisao_pos[next_path_index]
	var predicted_hit_normal: Vector2 = path_colisao_normal[next_path_index]

	var erro_hit_distancia = real_hit_pos.distance_to(predicted_hit_pos)
	var erro_hit_angulo = abs(real_hit_normal.angle_to(predicted_hit_normal))

	print("real : ", real_hit_pos)
	print("previ: ", predicted_hit_pos)

	print("real : ", real_hit_normal)
	print("previ: ", predicted_hit_normal)
	print()
	if erro_hit_distancia <= MAX_ERRO_DISTANCIA_COLISAO and erro_hit_angulo <= MAX_ERRO_ANGULO_COLISAO:
		velocity = global_position.direction_to(path_colisao_pos[next_path_index + 1]) * speed
		bullet_2.rotation = velocity.angle()
		current_path_index += 1
		return

print("deu ruim")
#seguindo_o_path = false
velocity = velocity.bounce(collision.get_normal())
bullet_2.rotation = velocity.angle()

func _draw() → void:
if path_colisao_pos.size() < 2:
return

for i in range(path_colisao_pos.size() - 1):
	var point_a = path_colisao_pos[i]
	var point_b = path_colisao_pos[i+1]
	draw_line(to_local(point_a), to_local(point_b), Color.WHITE, 2)