Build a Godot 2D Platformer That Actually Feels Responsive

Tune a responsive Godot 4 platformer controller with acceleration, coyote time, jump buffering, variable jump height, and a repeatable movement test loop.

Retro gaming setup with colorful screen
On this page

A platformer controller is a promise about how quickly the character obeys. The first version needs only horizontal intent, gravity, and a jump, but a useful tutorial must also show how to tune that promise. This guide builds a small CharacterBody2D controller, adds forgiveness deliberately, and gives you a test loop that exposes weak movement before a full level hides it.

Start With a Small Test Room

Create a CharacterBody2D root with a CollisionShape2D and a visible Sprite2D or Polygon2D. Put it in a test scene with a long floor, a one-tile gap, a low ceiling, a short ledge, and a landing platform. These shapes answer concrete questions: can the player stop before an edge, clear the standard gap, release a jump under a ceiling, and land where expected? Keep this room separate from the real level so cameras, art, hazards, and slopes cannot hide the source of a movement problem. The project structure guide shows how to keep reusable player and test scenes independent.

Map Intent Before Writing Movement

Add named actions such as move_left, move_right, and jump in Input Map. Gameplay code should ask for intent, not a particular keyboard key. This keeps keyboard, controller, rebinding, and accessibility work outside movement equations. Input intent is not velocity: a direction of negative one means the player wants to move left, not that the body should teleport to maximum left speed in one frame. Keeping the concepts separate creates a clear place for acceleration, deceleration, air control, knockback, and temporary movement locks.

Build the Minimum CharacterBody2D Loop

CharacterBody2D expects the script to calculate velocity and call move_and_slide(). Gravity changes vertical velocity while airborne. Horizontal intent moves current velocity toward a target speed. Export the tuning values and name them by behavior. Run speed controls the sustained promise, acceleration controls how quickly input is acknowledged, and deceleration controls precision near edges. This explicit loop is easier to inspect than position changes scattered across input callbacks. The physics guide explains the surrounding body and collision model.

gdscript
extends CharacterBody2D

@export var run_speed := 260.0
@export var acceleration := 1600.0
@export var deceleration := 2200.0
@export var jump_speed := 430.0
@export var gravity := 1200.0

func _physics_process(delta: float) -> void:
    var direction := Input.get_axis("move_left", "move_right")
    var target_speed := direction * run_speed
    var rate := acceleration if direction != 0.0 else deceleration
    velocity.x = move_toward(velocity.x, target_speed, rate * delta)
    if not is_on_floor():
        velocity.y += gravity * delta
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = -jump_speed
    move_and_slide()

Tune Movement Before Adding More Features

Signature lab · Movement tuner

Feel the controller values change

Adjust one controller model and watch the production-relevant values change.

Use the movement tuner here before implementing wall jumps, dashes, ladders, or attacks. Pick a target identity such as precise, balanced, or floaty. Change one family of values at a time; changing speed, acceleration, gravity, and jump force together produces a different result without explaining why. Measure simple outcomes. Time how long it takes to reach full speed, count the character widths needed to stop, and note approximate jump height. Exact numbers matter less than a repeatable comparison. A controller that feels good once may still fail when entering a narrow platform at maximum speed.

Add Coyote Time as a Forgiveness Window

Coyote time allows a jump briefly after the body leaves the floor. It corrects a perception mismatch: the player may believe they pressed jump at the edge while the fixed physics step already marked the body airborne. Track a countdown that resets while grounded and decreases in the air, then consume it when a jump succeeds. Start around a tenth of a second and test at real game speed. A large window resembles an accidental air jump; a tiny one may exist in code without improving play. The test-room edge is the fastest place to calibrate it.

gdscript
@export var coyote_time := 0.10
var coyote_left := 0.0

func update_coyote_time(delta: float) -> void:
    if is_on_floor():
        coyote_left = coyote_time
    else:
        coyote_left = maxf(coyote_left - delta, 0.0)

Buffer Jump Input Before Landing

Jump buffering solves the opposite timing problem. When jump is pressed just before landing, remember the request briefly and execute it when the body becomes grounded. Store a countdown on the press, reduce it each physics frame, and clear it when a jump starts. Keep this buffer independent from coyote time. Coyote time asks whether a recently grounded body may jump; buffering asks whether a recent input request remains valid. Combining them into one boolean makes failures difficult to diagnose and can allow stale input to fire after pauses, cutscenes, or movement locks.

Use Variable Jump Height Deliberately

A variable jump lets a short press produce a low hop and a held press produce a higher arc. A simple technique reduces upward velocity when jump is released while the body is still rising. Test minimum and maximum jumps against visible ledges. If both arcs reach almost the same height, the cut is too weak. If a tap feels like an invisible ceiling, soften the multiplier. Animation can sell anticipation and landing, but it should present the physics result rather than secretly changing it.

gdscript
@export_range(0.1, 1.0) var jump_cut_multiplier := 0.45

func apply_jump_cut() -> void:
    if Input.is_action_just_released("jump") and velocity.y < 0.0:
        velocity.y *= jump_cut_multiplier

Separate Ground and Air Control

Many controllers use lower acceleration or deceleration in the air. Add an air-control multiplier to the same target-speed approach instead of creating a second movement system. This preserves one model while making airborne direction changes less immediate. Precision platformers often allow strong correction; momentum games make it limited. Do not remove control merely because realistic bodies have inertia. Platformer movement communicates game rules, not a physical simulation. Choose the correction that supports your landing challenges and model knockback or scripted launches as explicit temporary states.

Debug Movement With Visible State

When a jump fails, display grounded state, coyote countdown, jump buffer, input edge, and velocity. A small debug label is more useful than repeatedly guessing at constants. Use the Remote scene tree to confirm the live body and collision shape. Check collisions before changing movement math: floor seams, collider corners, and low ceilings can all look like controller errors. Then check ownership. Velocity and move_and_slide() should have one authoritative writer in the physics loop, while animation and audio only present the result.

Finish With a Repeatable Feel Test

Run the same sequence after every meaningful change: accelerate to full speed, stop before an edge, tap and hold jump, jump just after leaving an edge, press jump just before landing, reverse direction in the air, and hit a low ceiling. Record the chosen values beside the intended feel. This turns taste into a testable design decision. Only then add camera response, particles, audio, and animation. Those layers create polish but should reinforce a dependable controller. Continue with the animation ownership guide when movement no longer needs effects to feel understandable.

Practice and Sources

Complete the tuner, copy its values into a tiny project, and write one sentence describing the intended movement promise. Verify each result in the five-shape test room. The official sources below document CharacterBody2D and input APIs; the workflow here is the production layer that turns those APIs into a controller you can reason about and improve without blindly replacing constants.

Frequently asked questions

Should platformer movement run in _process or _physics_process?
Apply CharacterBody2D velocity and call move_and_slide in _physics_process so movement follows the fixed physics step.
What makes a platformer controller feel responsive?
Fast acceleration, deliberate stopping, predictable air control, coyote time, jump buffering, and variable jump height usually matter more than adding more abilities.
How much coyote time should a Godot platformer use?
Start near 0.1 seconds and test it with the actual camera, character scale, and level speed. Treat it as a tuning value, not a universal constant.
Verification notes

Sources and revision context

Rebuilt as a tuning workflow instead of a code dump. The controller separates intent, velocity, forgiveness windows, and verification so each change has a measurable effect.