HCR suspensions physics in Godot

Godot Version

Godot 4.6.3


Hello Godot community,

I’m currently trying to create a 2D road trip game with Hill Climb Racing physics and mechanics, but I’m currently stuck and I tried many solution to find the issue, but it still doesn’t work as I wish. When landing, one of the wheel will just not reset to its rest pose (like in the picture, the rear wheel is supposed to be at the same height as the front one). Does anyone could help me on this problem? I just don’t really like to use AI and things like this, so this is why I went on this forum to see for people that knows a lot better than I do.

(Some part of my comments in script are in French, sorry)

This is the car script

extends RigidBody2D

var wheel_fr: RigidBody2D
var wheel_rr: RigidBody2D

@export var rest_length := 60.0
@export var stiffness   := 800.0
@export var damping     := 80.0
@export var power       := 400.0
@export var max_speed   := 40.0

func _physics_process(_delta: float) -> void:
	if wheel_fr == null or wheel_rr == null:
		return
	_apply_suspension($AnchorFR.global_position, wheel_fr)
	_apply_suspension($AnchorRR.global_position, wheel_rr)
	_handle_input()

func _apply_suspension(origin: Vector2, wheel: RigidBody2D) -> void:
	var down := global_transform.y.normalized()

	var to_wheel     := wheel.global_position - origin
	var current_dist : float = to_wheel.dot(down)
	var compression  : float = rest_length - current_dist

	var wheel_vel   : float = wheel.linear_velocity.dot(down)
	var chassis_vel : float = linear_velocity.dot(down)
	var rel_vel     : float = wheel_vel - chassis_vel

	# Amortissement asymétrique / asymmetric damping :
	# - compression rapide (roue monte) → amorti fort / fast compression (wheel up) → strong damping
	# - extension (roue descend) → amorti plus doux pour garder le contact sol / extension (wheel lowers) → softer damping to maintain contact with the ground
	var damping_factor: float
	if rel_vel > 0.0:
		# Roue remonte → compression → amorti fort / Wheel lift → compression → firm damping
		damping_factor = damping * 2.5
	else:
		# Roue descend → extension → amorti doux / Wheel comes down → extension → soft landing
		damping_factor = damping * 0.8

	var force_mag: float = clamp(
		compression * stiffness - rel_vel * damping_factor,
		-6000.0, 6000.0
	)

	var force := down * force_mag
	apply_central_force(-force)
	wheel.apply_central_force(force)

	# Réaction du sol / Ground Reaction
	var space := get_world_2d().direct_space_state
	var query := PhysicsRayQueryParameters2D.create(
		origin, origin + down * (rest_length + 20.0)
	)
	query.exclude = [get_rid(), wheel.get_rid()]
	var hit := space.intersect_ray(query)

	if not hit.is_empty():
		var ground_dist : float = origin.distance_to(hit.position)
		var ground_comp : float = clamp((rest_length + 20.0) - ground_dist, 0.0, 20.0)
		apply_central_force(-down * ground_comp * stiffness * 0.5)

func _handle_input() -> void:
	if Input.is_action_pressed("right"):
		apply_torque(-150.0)
		if wheel_fr.angular_velocity < max_speed:
			wheel_fr.apply_torque(power)
		if wheel_rr.angular_velocity < max_speed:
			wheel_rr.apply_torque(power)
	elif Input.is_action_pressed("left"):
		apply_torque(150.0)
		if wheel_fr.angular_velocity > -max_speed:
			wheel_fr.apply_torque(-power)
		if wheel_rr.angular_velocity > -max_speed:
			wheel_rr.apply_torque(-power)

I also have this code that initialize the level and the car (the player).

extends Node2D

func _ready() -> void:
	var player   := $Player
	var wheel_fr := $WheelFR
	var wheel_rr := $WheelRR

	wheel_fr.global_position = player.get_node("AnchorFR").global_position
	wheel_rr.global_position = player.get_node("AnchorRR").global_position

	_make_groove(player, wheel_fr, player.get_node("AnchorFR").global_position)
	_make_groove(player, wheel_rr, player.get_node("AnchorRR").global_position)

	player.wheel_fr = wheel_fr
	player.wheel_rr = wheel_rr

func _make_groove(player: RigidBody2D, wheel: RigidBody2D, anchor: Vector2) -> void:
	var joint              := GrooveJoint2D.new()
	joint.global_position  = anchor
	# L'axe du groove suit la rotation du chassis — pas besoin de rotation manuelle
	joint.rotation         = player.rotation
	joint.length           = 60.0   # Débattement de la suspension (en pixels)
	joint.initial_offset   = 1.0    # Minimum imposé par Godot
	joint.node_a           = player.get_path()
	joint.node_b           = wheel.get_path()
	add_child(joint)

Her is a nice tutorial for creating such a game, i never dit this and i am new to godot as well but this lock helpfull.

God luck with it!