Quick answer: Apply gravity each frame, then jump if grounded, then call move_and_slide. Only zero velocity.y when actually on the floor (and even then, leave a small negative value to keep the body grounded). Adjust floor_snap_length to 0 when jumping so snap does not pull you back.
Here is how to fix Godot 4 CharacterBody3D where vertical velocity snaps to zero unexpectedly, killing jumps mid-air or preventing falls. Many tutorials zero velocity.y unconditionally; this conflicts with the way move_and_slide handles floor contact and produces a stuck character. The right pattern is gravity-then-jump-then-move, with conditional zeroing only when grounded.
The Symptom
Player presses Jump. Character barely lifts then drops back to ground. Or character refuses to fall off ledges, hovering at the edge. Print statements show velocity.y mysteriously zero immediately after being set to a positive value.
What Causes This
Unconditional velocity.y = 0. Code like if is_on_floor(): velocity.y = 0 is correct. velocity.y = 0 at the top of every frame is wrong.
floor_snap_length too aggressive. Snap pulls the character down to the floor each move_and_slide. With too much snap, jumps are absorbed.
Gravity applied after move_and_slide. If you call move_and_slide first then add gravity, the next frame applies gravity to a velocity that should already have lifted off.
is_on_floor stale. is_on_floor reflects the previous physics step. Right after a jump, is_on_floor still returns true for one frame.
The Fix
Step 1: Standard CharacterBody3D loop.
extends CharacterBody3D
const SPEED := 5.0
const JUMP_VELOCITY := 5.0
const GRAVITY := 9.8
func _physics_process(delta: float):
# 1. Apply gravity if not on floor
if not is_on_floor():
velocity.y -= GRAVITY * delta
# 2. Jump if grounded and pressed
if is_on_floor() and Input.is_action_just_pressed("jump"):
velocity.y = JUMP_VELOCITY
floor_snap_length = 0.0 # Disable snap during jump
elif is_on_floor():
floor_snap_length = 0.5
# 3. Horizontal input
var dir := Input.get_vector("left", "right", "forward", "back")
velocity.x = dir.x * SPEED
velocity.z = dir.y * SPEED
# 4. Move
move_and_slide()
Order matters: gravity first, jump second, horizontal third, move last.
Step 2: Don’t zero velocity.y unconditionally. Some tutorials do this for resetting state. It breaks jumps because gravity has not had a chance to accumulate. Just rely on the floor contact to clamp it via move_and_slide’s slide vectors.
Step 3: Manage floor_snap_length conditionally. Set to 0 when jumping; set positive (e.g., 0.5) when grounded for ledge-step traversal.
Step 4: Distinguish jump-just-pressed vs jump-still-held. is_action_just_pressed fires once per press. is_action_pressed stays true while held. For variable jump height, increase gravity when jump is released early:
if velocity.y > 0 and not Input.is_action_pressed("jump"):
velocity.y -= GRAVITY * delta * 2 # cut jump short
Step 5: Coyote time and jump buffer for forgiving feel.
var coyote := 0.1
var coyote_timer := 0.0
func _physics_process(delta):
if is_on_floor():
coyote_timer = coyote
else:
coyote_timer = max(coyote_timer - delta, 0)
if coyote_timer > 0 and Input.is_action_just_pressed("jump"):
velocity.y = JUMP_VELOCITY
coyote_timer = 0
Common Mistakes
Calling velocity.y = 0 in _physics_process top. Breaks gravity accumulation entirely.
Setting velocity = Vector3.ZERO at start of frame — same issue but for all axes.
Calling move_and_slide() multiple times in one frame. The second call uses post-move velocity, often wrong.
“Gravity, jump, move. In that order. Don’t reset velocity.y unless you know why.”
Related Issues
For 2D character ghost collisions, see CharacterBody2D Ghost Collision. For collision shape disable, see CollisionShape Disabled.
Three steps: gravity, jump, move. Coyote time as a bonus. The jump feels right.