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.
You can feel that window in the coyote time lab. It puts the grace period on a slider and times every late press.
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.0To feel it before you code it, try the jump buffering lab. Press early, switch the buffer off and on, and watch each press land on a timing strip.
Coyote 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.

