Platformer movement lives or dies on feel. The difference between a jump that feels great and one that feels stiff isn't the engine, it's a handful of small touches most tutorials skip. Let's build movement that feels good, not just movement that works.
The basic setup
Use a CharacterBody2D with a Sprite2D and a CollisionShape2D, same as any 2D character. Add input actions for move_left, move_right, and jump in Project Settings, Input Map. Then the bones of it:
extends CharacterBody2D@export var speed: float = 300.0@export var jump_velocity: float = -400.0func _physics_process(delta: float) -> void: # Gravity pulls you down every frame you're in the air if not is_on_floor(): velocity += get_gravity() * delta # Jump if Input.is_action_just_pressed("jump") and is_on_floor(): velocity.y = jump_velocity # Left and right var direction := Input.get_axis("move_left", "move_right") velocity.x = direction * speed move_and_slide()That's a working platformer character. get_gravity() pulls the project's gravity setting, so you tune it in one place. is_on_floor() only works because of move_and_slide(), which figures out what you're standing on. And note jump_velocity is negative, because in 2D, up is negative Y.
This works. But it doesn't feel good yet. Here's why.
Coyote time
Real players press jump a few frames after walking off a ledge, then get annoyed when nothing happens. Coyote time gives them a tiny grace window where a jump still counts even though they've technically left the floor. Named after a certain cartoon coyote who hangs in the air a moment too long.
@export var coyote_time: float = 0.1var coyote_timer: float = 0.0func _physics_process(delta: float) -> void: if not is_on_floor(): velocity += get_gravity() * delta coyote_timer -= delta else: coyote_timer = coyote_time if Input.is_action_just_pressed("jump") and coyote_timer > 0.0: velocity.y = jump_velocity coyote_timer = 0.0 var direction := Input.get_axis("move_left", "move_right") velocity.x = direction * speed move_and_slide()One hundred milliseconds is invisible to the player but makes the controls feel forgiving instead of punishing.
Jump buffering
The flip side: players hit jump just before they land. A jump buffer remembers that press for a few frames and fires it the instant they touch ground:
@export var jump_buffer_time: float = 0.1var jump_buffer_timer: float = 0.0func _physics_process(delta: float) -> void: if Input.is_action_just_pressed("jump"): jump_buffer_timer = jump_buffer_time else: jump_buffer_timer -= delta # ... gravity and coyote logic ... if jump_buffer_timer > 0.0 and coyote_timer > 0.0: velocity.y = jump_velocity jump_buffer_timer = 0.0 coyote_timer = 0.0Coyote time and jump buffering together are the single biggest upgrade you can make to a platformer's feel, and they're maybe ten lines.
A snappier fall
Floaty jumps feel bad. A cheap fix is to fall faster than you rise. Multiply gravity when the player is moving downward:
if velocity.y > 0: velocity += get_gravity() * delta * 1.5Small number, big difference. The character snaps back to the ground instead of drifting.
Where movement becomes a game
Tight movement is the price of entry for a platformer, but it's just the start. The moment you add running, dashing, wall jumps, and attacks, you'll feel the code turn into a mess of booleans fighting each other. That's the problem a state machine solves, and it's worth reading before your player script gets out of hand.
If you want a structured path that builds movement fundamentals the right way, the free 2D Top-Down Movement quest covers the same CharacterBody2D base these platformer mechanics sit on.
FAQ
How do I add gravity to a CharacterBody2D in Godot 4?
Call get_gravity() and add it to velocity each frame while the body is in the air: velocity += get_gravity() * delta inside an if not is_on_floor() check. get_gravity() reads the project's default gravity, so you tune it in Project Settings.
What is coyote time and why do I need it?
Coyote time is a short grace period after walking off a ledge where a jump still registers. Players naturally press jump a few frames late, and without this window the controls feel unfair. A tenth of a second is enough to feel right without looking like a bug.
Why is my jump velocity negative?
In Godot's 2D coordinate system, the Y axis points down, so negative Y is up. To launch the character upward you set velocity.y to a negative number like -400. A positive value would push them into the floor.