How to implement deceleration with a CharacterBody3D node via code?

Godot Version

4.4 stable

Question

So, I’m currently working on my player’s movement and overall game feel. I got acceleration working nicely, but deceleration is a bit trickier.

I use a CharacterBody3D node as the root of the player in order to avoid conflicts with non-deterministic physics over the network (client and server desync). It works well.

However, all physics must be implemented with code according to the documentation. I’m fine with this, however, I am not proficient in physics logic and especially math. So I’m a little stumped on how I can implement deceleration.

Basic Concept:

(All the code is below this basic explanation)

The movement action is split into two functions:

  • start_moving and stop_moving

It’s done like this in order to control the exact reaction when the player presses or lets go of the movement input. (I’m also used to this implementation from Unreal 4/5)

When the player presses a movement input, start_moving will trigger. This will add a force to the player’s velocity based on a Current_Movement_Speed variable, which is then added to every physics tick via the MOVEMENT_ACCELERATION_RATE.

This works wonders. The player feels really nice to control now.

For deceleration however, I want it to work a bit differently.

When the player has no movement input buttons pressed, stop_moving will trigger. What I want to happen is the following:

  1. The player’s current velocity is slowed down every physics tick by the MOVEMENT_DECELERATION_RATE constant(s).
  2. If the player is on the ground, the deceleration rate is quick.
  3. If the player is in the air, there is barely any deceleration.
    This makes it so the player doesn’t come to a instant halt when the movement inputs are released mid-air and allows the player to carry momentum via jumping.

However, I do not know how to implement deceleration. When the movement inputs are released, the velocity added from start_moving instantly vanishes.

I could store the current velocity, but I’m not 100% sure where to put that in my logic.

(By the way, the input code works and the logic that determines if the player is airborne works)

Here’s all the code below.

Movement Variables and Constants:

# Movement
var Current_Movement_Speed : float = 0.0
const MAX_MOVEMENT_SPEED : float = 50.0
const MIN_MOVEMENT_SPEED : float = 0.0

const MOVEMENT_ACCELERATION_RATE : float = 5.0

const MOVEMENT_DECELERATION_RATE_GROUNDED : float = 2.5
const MOVEMENT_DECELERATION_RATE_AIRBORNE : float = 0.001

start_moving

func start_moving(input:Vector2) -> void:
	
	# This trigger when the player starts moving
	
	# Works by having a small base speed,-
	# -then increasing that speed via acceleration
	
	# Set moving to true
	Is_Moving = true
	
	# Get the movement direction and normalize it
	var Movement_Direction:Vector3 = Global_Rotation_Pivot.basis \
	* Vector3(input.x, 0, input.y).normalized()
	
	# Movement acceleration (Advances every physics tick)
	if Current_Movement_Speed < MAX_MOVEMENT_SPEED:
		Current_Movement_Speed = Current_Movement_Speed + MOVEMENT_ACCELERATION_RATE
	
	# Movement force
	velocity.x = Movement_Direction.x * Current_Movement_Speed
	velocity.z = Movement_Direction.z * Current_Movement_Speed

stop_moving

func stop_moving() -> void:
	
	# This triggers when the player stops moving.
	
	# Set is_moving to false
	Is_Moving = false
	
	# Movement Deceleration (DOES NOT WORK)
	if Current_Movement_Speed > MIN_MOVEMENT_SPEED:
		
		if Is_Airborne == false:
			Current_Movement_Speed = Current_Movement_Speed - MOVEMENT_DECELERATION_RATE_GROUNDED
		elif Is_Airborne == true:
			Current_Movement_Speed = Current_Movement_Speed - MOVEMENT_DECELERATION_RATE_AIRBORNE

What I think is happening is the Current_Movement_Speed variable isn’t the cause of the player’s velocity. The variable just controls how much velocity is gained. But I can’t find a way to store it currently.

I would appreciate any help I could get on this and maybe some more info about Godot’s 3D physics as well.

When exactly are start_moving and stop_moving called? They look like they should be frame-rate dependent, but I don’t see a delta (time between last frame current) being used. And you also need to modify the velocity variable inside the stop_moving function to change the player’s real speed.

1 Like

Will do.

The input is handled in my client_manager script. (I’ll include the code below)

I did it this way because I wanted the client’s input to be separate from their assigned player and the dedicated server’s version of that player.

This is because the client only sends input to the server, as it is the main source of truth. The client’s player is just a prediction and will be corrected if it does not match the server’s state (within reason).

The inputs themselves are tracked as variables via the client’s _process function. This allows me to know if the buttons are being pressed, held, and if the input is new.

Then, the client’s _physics_process function checks to see if the associated input variables are true/false, and acts accordingly at a rate of 60 times per second.

Variables:

# Client Movement Input
var Is_Holding_Move_Forward_Input : bool = false #Default Value
var Is_Holding_Move_Backward_Input : bool = false #Default Value
var Is_Holding_Move_Left_Input : bool = false #Default Value
var Is_Holding_Move_Right_Input : bool = false #Default Value

Movement Input Tracking Function:

func track_movement_input() -> void:
	
	# Tracks what movement key is being held and not held
	# Used for input-to-action logic across the client, server, and player
	
	# Move forward (Default: W Key)
	if Input.is_action_pressed("move_forward"):
		Is_Holding_Move_Forward_Input = true
	else: 
		Is_Holding_Move_Forward_Input = false
	
	# Move backward (Default: S Key)
	if Input.is_action_pressed("move_backward"):
		Is_Holding_Move_Backward_Input = true
	else: 
		Is_Holding_Move_Backward_Input = false
	
	# Move left (Default: A Key)
	if Input.is_action_pressed("move_left"):
		Is_Holding_Move_Left_Input = true
	else: 
		Is_Holding_Move_Left_Input = false
	
	# Move right (Default: D Key)
	if Input.is_action_pressed("move_right"):
		Is_Holding_Move_Right_Input = true
	else: 
		Is_Holding_Move_Right_Input = false

_process:

@warning_ignore("unused_parameter")
func _process(delta: float) -> void:
	
	# Prevent error from sending an RPC too early
	if Is_Connected == false: return
	
	# ---------------------------
	# ---------------------------
	
	# Tracks the current movement buttons being held and not held
	track_movement_input()
	
	# ---------------------------
	# ---------------------------

_physics_process:

@warning_ignore("unused_parameter")
func _physics_process(delta: float) -> void:
	
	# Prevent error from sending an RPC too early
	if Is_Connected == false: return
	
	# ---------------------------
	# ---------------------------
	
	# Progress client-side clock
	Client_Clock += delta
	
	# ---------------------------
	# ---------------------------
	
	# Client predicted movement call
	
	# Checks if the player is holding any movement button
	if Is_Holding_Move_Forward_Input == true \
	or Is_Holding_Move_Backward_Input == true \
	or Is_Holding_Move_Right_Input == true \
	or Is_Holding_Move_Left_Input == true:
		
		if Player_Container.has_node(str(multiplayer.get_unique_id())):
			
			# Get player input vectors
			var input:Vector2 = Input.get_vector("move_right", "move_left", "move_backward", "move_forward")
			
			# Move the player on the client
			Player_Container.get_node(str(multiplayer.get_unique_id())).start_moving(input)
			
			# Server movement call
			network_start_moving.rpc_id(1,\
			multiplayer.get_unique_id(),\
			(Input.get_vector("move_right", "move_left", "move_backward", "move_forward")))
			

	# Checks if the player has all movement keys released to stop moving
	if Is_Holding_Move_Forward_Input == false \
	and Is_Holding_Move_Backward_Input == false \
	and Is_Holding_Move_Right_Input == false \
	and Is_Holding_Move_Left_Input == false:
		
		# Client-side stop moving trigger
		Player_Container.get_node(str(multiplayer.get_unique_id())).stop_moving()
		
		# Server-side stop moving trigger
		network_stop_moving.rpc_id(1, multiplayer.get_unique_id())

I think in the stop moving function, you should set the velocity to the current movement speed at the end, but in the same direction as the current velocity. Also, you should multiply acceleration and deceleration by delta to make it frame independent.

1 Like

In both start_moving() and stop_moving() I’d advise clamping; particularly in the stop_moving() case, you could actually see Current_Movement_Speed go slightly negative. Say someone is airborne, and speed comes down to 0.1 from deceleration in the air, then they hit the ground. The next update is going to subtract 2.5, and you’ll get a movement speed of -1.5.

Once you factor delta in (which, as @paintsimmon says, you really should if you want consistent results), you can get overshoot on top speed as well.

A simple:

Current_Movement_Speed = clampf(Current_Movement_Speed + (delta * ACCEL), MIN_SPEED, MAX_SPEED)

will do the job. Or you can simplify it a bit and do it manually:

Current_Speed += delta * ACCEL
if Current_Speed > MAX_SPEED: Current_Speed = MAX_SPEED

[...]

Current_Speed -= delta * DECEL
if Current_Speed < MIN_SPEED: Current_Speed = MIN_SPEED
1 Like

Alright, I got deceleration fully implemented.

Thanks to @paintsimmon and @hexgrid for their very help.

So the code is the following:

Variables and Constants:

# Movement
var Current_Movement_Speed : float = 0.0
const MAX_MOVEMENT_SPEED : float = 30.0
const MIN_MOVEMENT_SPEED : float = 0.0

const MOVEMENT_ACCELERATION_RATE : float = 250.0

const MOVEMENT_DECELERATION_RATE_GROUNDED : float = 350.0
const MOVEMENT_DECELERATION_RATE_AIRBORNE : float = 10.0

var Global_Movement_Direction : Vector3 
var Movement_Direction_X : float
var Movement_Direction_Z : float

start_moving and stop_moving:

func start_moving(input:Vector2, delta:float) -> void:
	
	# This trigger when the player starts moving
	
	# Works by having a small base speed,-
	# -then increasing that speed via acceleration
	
	# Set moving to true
	Is_Moving = true
	
	# Set the current movement direction
	Global_Movement_Direction = Global_Rotation_Pivot.basis \
	* Vector3(input.x, 0, input.y).normalized()
	
	# Movement acceleration 
	# (Advances every physics tick in the client script)
	if Current_Movement_Speed < MAX_MOVEMENT_SPEED:
		
		Current_Movement_Speed = \
		clampf(Current_Movement_Speed + \
		(delta * MOVEMENT_ACCELERATION_RATE), \
		MIN_MOVEMENT_SPEED, MAX_MOVEMENT_SPEED)
	
	# Movement force
	velocity.x = Global_Movement_Direction.x * Current_Movement_Speed
	velocity.z = Global_Movement_Direction.z * Current_Movement_Speed
	
	# Prevents saving a 0.0 input-
	# -and killing the player's momentum
	if input != Vector2(0,0):
		Movement_Direction_X = Global_Movement_Direction.x
		Movement_Direction_Z = Global_Movement_Direction.z

func stop_moving(delta:float) -> void:
	
	# This triggers when the player stops moving.
	
	# Set Is_Moving to false
	Is_Moving = false
	
	# Add remaining velocity to movement
	velocity.x = Movement_Direction_X * Current_Movement_Speed
	velocity.z = Movement_Direction_Z * Current_Movement_Speed
	
	# Movement Deceleration
	if Current_Movement_Speed > MIN_MOVEMENT_SPEED:
		
		# If grounded
		if Is_Airborne == false:
			
			Current_Movement_Speed = \
			clampf(Current_Movement_Speed - \
			(delta * MOVEMENT_DECELERATION_RATE_GROUNDED), \
			MIN_MOVEMENT_SPEED, MAX_MOVEMENT_SPEED)
			
		# If airborne
		elif Is_Airborne == true:
			
			Current_Movement_Speed = \
			clampf(Current_Movement_Speed - \
			(delta * MOVEMENT_DECELERATION_RATE_AIRBORNE), \
			MIN_MOVEMENT_SPEED, MAX_MOVEMENT_SPEED)

Result:

What’s happening is I’m letting go of the movement input, then the momentum is carried instead of vanishing.

2 Likes

This topic was automatically closed 30 days after the last reply. New replies are no longer allowed.