How to have player stacking/ boosting like in CSGO and Source games?

Godot Version

v4.4.1.stable.steam

Question

Here is what I am dealing with, when I get two players, one on the other, the bottom one gets stuck when close to walls.

Also, sometimes the player can get stuck as soon as the other jumps on, or randomly on flat surfaces.

The problem is fixed always when the player on top gets off. The act of jumping on the other player is a problem due to some kind of physics interaction.

The movement of characters is always fine as long as there is no player on top so I think my movement script is fine. I need a solution that allows me to have players on top of each other while moving, and not get stuck.

Here is a demonstration video:

I know when a player is stuck by a couple frames of it happening, and then I call a function to move the player on top up through applying a small upward force. Because it takes a couple frames this solution is not the best because if you tap to move instead of holding you can stay stuck, because the system needs consistency to avoid false positives. It averages movement, speed, and input in order to try and detect when they player is stuck. So intermittent input is ignored like tapping if it doesn’t pass the thresholds.

Here is my attempt at a solution

How do I tackle this issue? I think my solution could work but it would require more control over my variables and being more accurate. I think it takes 1-3 frames to detect when the player underneath is stuck. I don’t like how it knocks the player off, but that is unavoidable because I am pushing it up, while the player underneath moves away. It’s only natural they fall off.

In the inspector I have tried to mess with physics by increasing the margin on my CollisionShape3D for the player. Increasing it a little from 0.04 to 0.1 helped but I still apply upward forces to avoid getting stuck. I tried setting it to 1m and it got rid of all the issues I was facing, but I know that is not a solution.

I wanted to also add some code and logs to illustrate better the problem.

I can supply as much code as needed, but this is my logic to detect when the player is stuck
My physics_proccess:

func _physics_process(delta: float) -> void:
	# Gravity handling
	if not is_on_floor():
		_velocity.y += gravity * delta
		coyote_timer += delta
	else:
		if _velocity.y < 0:
			_velocity.y = 0
		coyote_timer = 0.0

	# Jump buffering
	if jump_buffer_timer < JUMP_BUFFER_TIME:
		if coyote_timer < COYOTE_TIME and not _is_head_blocked():
			_velocity.y = _pending_jump_impulse
			jump_buffer_timer = JUMP_BUFFER_TIME + 1.0

	jump_buffer_timer += delta

	# Syncing velocity for movement
	velocity = _velocity
	
	# Track input timing for stability
	if _client_attempted_move:
		time_with_input += delta
		time_without_input = 0.0
		if not received_recent_input and time_with_input >= INPUT_RECENT_THRESHOLD:
			received_recent_input = true
	else:
		time_without_input += delta
		time_with_input = 0.0
		if received_recent_input and time_without_input >= INPUT_RECENT_THRESHOLD:
			received_recent_input = false

	# Track average horizontal speed (ignore vertical axis)
	speed_tracker.update(delta)
	var average_speed = speed_tracker.get_result()
	var is_speed_above_threshold = average_speed > MOVING_SPEED_THRESHOLD

	# Track physical displacement over time (horizontal+vertical)
	movement_tracker.update(delta)
	var average_movement_distance = movement_tracker.get_result()
	var not_enough_movement = average_movement_distance < STUCK_MOVEMENT_THRESHOLD

	# Move previous_position update AFTER tracker update
	previous_position = position

	# Determine stuck conditions
	is_stuck_conditions_met = _client_attempted_move \
		and is_speed_above_threshold \
		and not_enough_movement \
		and received_recent_input

	# Ratio of time we’ve met stuck condition
	stuck_tracker.update(delta)
	var stuck_ratio := stuck_tracker.ratio_true()
	var confirmed_stuck := stuck_ratio >= STUCK_MIN_RATIO


	if confirmed_stuck:
		check_and_unstick_players()
		pass

	 #Optional: Debug print only on server-host player
	if multiplayer.is_server() and GlobalMultiplayerHandler.is_host_player(get_multiplayer_authority()):
		print(GlobalUtilities._prefix(multiplayer.get_unique_id()),
			confirmed_stuck, " ", stuck_ratio,
			"    INDV: ", _client_attempted_move, " ", is_speed_above_threshold, " ", not_enough_movement, " ", received_recent_input,
			" average_speed: ", average_speed,
			" average_distance: ", average_movement_distance
		)

	# Visual rotation handling
	var target_ang = Vector3.BACK.signed_angle_to(_direction, Vector3.UP)
	var current_yaw = visuals_holder.rotation.y
	var new_yaw = lerp_angle(current_yaw, target_ang, rotation_speed * delta)
	_locally_sync_rotations(Vector3(0, new_yaw, 0))

	# Final movement step
	velocity += external_velocity
	external_velocity = Vector3.ZERO
	move_and_slide()

Here is some logs explaining how the logic works. These are logs of the lower player only.
Scenario: I put the player in a corner next to walls and have another jump on top. Then have the lower player hold a movement key to get out of the corner which then triggers the stuck logic.

is_stuck = confirmed_stuck on average within 10 frames, where 1 frame is stuck or more
stuck_ratio = is_stuck_conditions_met / frames (new frame in, old one out, and keep only 10)
a = _client_attempted_move | if on this tick the player sent movement input
b = is_speed_above_threshold | on average is the speed more than than a threshold
c = not_enough_movement | on average is the distance moved less than a threshold
d = received_recent_input | if input held for threshold, stays true unless not held for a time.

received_recent_input also stays true if you send input before the decay timer fully ticks.
This logic avoids false positives after afking, and then moving. It also helps ignore input noise.

In the logs below I wait a bit, and then I hold down a movement key. the player is not confirmed stuck for a couple frames until it is. You’ll noticed the player is still considered stuck on ticks where not all the bools are true, that is because if the player is stuck more than or equal to a frame, within 10 frames then the player is considered stuck. This is to allow the stuck logic to trigger some more because it’ll take a few pushes to get the player underneath unstuck over a few frames.

(Tick)  [HOST   ID 1          | Player 1 ]  is_stuck  stuck_ratio  INDV:  a     b     c     d    average_speed          average_distance
(448)   [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  false false true  false 0.0                   0.0
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  false true  false 0.06666666865349      0.0
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  true  false 0.20000000596046      0.00879679061472
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  false false 0.40000000596046      0.01435231883079
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  true  false 0.66666668057442      0.00593337323517
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  true  false 1.00000002980232      0.0003778450191
        [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  true  false 1.33333336114883      0.00032512843609
        [HOST   ID 1          | Player 1 ]  true      0.1          INDV:  true  true  true  true  1.66666672229767      0.00038428604603
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  true  true  2.00000002384186      0.00034288316965
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  2.33333330154419      0.02254317700863
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  2.66666655540466      0.02322072908282
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  2.99999985694885      0.0287390742451
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  3.33333311080933      0.05833331122994
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  3.66666641235352      0.03067534230649
        [HOST   ID 1          | Player 1 ]  true      0.3          INDV:  true  true  true  true  3.99999966621399      0.00011982768774
        [HOST   ID 1          | Player 1 ]  true      0.3          INDV:  true  true  false true  4.33333287239075      0.03888893127441
        [HOST   ID 1          | Player 1 ]  true      0.3          INDV:  true  true  false true  4.59999957084656      0.03899350017309
        [HOST   ID 1          | Player 1 ]  true      0.3          INDV:  true  true  true  true  4.79999961853027      0.00017720460892
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  4.93333301544189      0.04173928499222
        [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  4.99999980926514      0.08333329856396
(4)     [HOST   ID 1          | Player 1 ]  true      0.2          INDV:  true  true  false true  5.0                   0.08333329856396
(3)     [HOST   ID 1          | Player 1 ]  true      0.1          INDV:  true  true  false true  5.0                   0.08333329856396
(7)     [HOST   ID 1          | Player 1 ]  false     0.0          INDV:  true  true  false true  5.0                   0.08333329856396