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