A Godot 4 2D platformer controller is the block of code that turns arrow keys into a character who runs, jumps, and lands like it belongs in a real game. Godot’s built-in template gets you moving in about ten lines, but the gap between “the character moves” and “the movement feels good” is where most new projects stall. This tutorial builds the whole thing in stages: a basic CharacterBody2D that walks and jumps, then acceleration and friction, then the game-feel tricks that separate a stiff prototype from a jump that feels fair. Every snippet is current Godot 4.x GDScript, and the full script is assembled at the end.

You do not need to memorize any of it. Build each layer, run the scene, feel the difference, then move to the next one.

Key Takeaways

  • Start with CharacterBody2D: set velocity, call move_and_slide() with no arguments, and check is_on_floor() after the move, not before.
  • Use move_toward for feel: lerping velocity with acceleration and friction values beats snapping velocity.x to full speed instantly.
  • Coyote time and jump buffering: two short timers (about 0.1 seconds each) mean the jump almost never has to be frame-perfect.
  • Variable jump height and faster falling: cut the upward velocity on early release, and apply stronger gravity while falling, so the arc feels weighty instead of floaty.
  • Godot 3 code will not work: the old move_and_slide(velocity, ...) signature was removed in Godot 4. Velocity is now a property, and the call takes no arguments.

What You Need First

You need Godot 4.x installed and a basic project open. This guide uses GDScript, Godot’s built-in language, because it is the fastest way to prototype movement. If you are weighing your options, our comparison of GDScript vs C# in Godot covers when each one makes sense, and if you are still choosing an engine at all, Godot vs Unity lays out the tradeoffs.

One setup step matters before any code runs. Open Project Settings, then Input Map and add three actions: move_left, move_right, and jump. Bind them to your preferred keys (A/D and Space is common). The script below reads those action names, so they have to exist first. Completely new to game development? Start with our game development roadmap and come back here for the movement layer.

Setting Up the CharacterBody2D Scene

Godot 4 editor showing a 2D platformer scene with a TileMap and node tree
A CharacterBody2D with a sprite and a collision shape is all the player node needs to start.

Create a new scene with a CharacterBody2D as the root node. It is the node type built specifically for characters you move with code, and it comes with move_and_slide() and floor detection ready to use. Add two children to it:

  • Sprite2D (or AnimatedSprite2D) for the character’s visuals. Drop in any texture for now.
  • CollisionShape2D with a rectangle or capsule shape sized to the sprite. Without it, the body passes through everything.

Save the scene, then attach a new GDScript to the CharacterBody2D root. That script file is where every snippet below lives. Your ground and platforms can be a TileMapLayer or plain StaticBody2D nodes; anything with collision counts as floor.

Basic Movement: Walking and Gravity

Here is the minimum viable platformer. It applies gravity, moves left and right, and slides along collisions. This is close to the template Godot generates when you attach a script to a CharacterBody2D.

extends CharacterBody2D

const SPEED = 300.0

func _physics_process(delta: float) -> void:
	# Apply gravity while airborne
	if not is_on_floor():
		velocity += get_gravity() * delta

	# Read horizontal input (-1, 0, or 1)
	var direction := Input.get_axis("move_left", "move_right")
	velocity.x = direction * SPEED

	move_and_slide()

Three things carry the weight here. get_gravity() returns the gravity vector from your project settings, so you never hardcode it. Input.get_axis() returns -1, 0, or 1 in one line instead of two if-statements. And move_and_slide() takes no arguments in Godot 4; it reads and writes the velocity property automatically. If you followed a Godot 3 tutorial that wrote velocity = move_and_slide(velocity), that signature no longer exists and will throw an error.

Adding a Jump

A jump is a single upward push against gravity. Because Godot’s Y axis points down, an upward jump is a negative value. Add a jump constant and one input check inside _physics_process, before move_and_slide():

const JUMP_VELOCITY = -400.0

# Inside _physics_process, after gravity:
if Input.is_action_just_pressed("jump") and is_on_floor():
	velocity.y = JUMP_VELOCITY

is_action_just_pressed fires on the single frame the key goes down, so the player jumps once per press instead of flying while the key is held. Pairing it with is_on_floor() stops mid-air jumps. One rule to remember: is_on_floor() only returns a correct value after move_and_slide() has run at least once, so read it at the top of the next frame, which is exactly what this loop does.

Acceleration and Friction

Snapping velocity.x straight to full speed makes the character feel like it is on ice in reverse: instant starts and instant stops. Real platformers ramp speed up and down. The move_toward() function nudges a value toward a target by a fixed step each frame, which is exactly what acceleration and friction are.

const SPEED = 300.0
const ACCELERATION = 1500.0
const FRICTION = 1800.0

var direction := Input.get_axis("move_left", "move_right")
if direction != 0.0:
	velocity.x = move_toward(velocity.x, direction * SPEED, ACCELERATION * delta)
else:
	velocity.x = move_toward(velocity.x, 0.0, FRICTION * delta)

When there is input, velocity moves toward full speed at the acceleration rate. When there is none, it moves toward zero at the friction rate. Higher numbers feel snappier, lower numbers feel floatier. Tune these two values first; they change the character’s personality more than any other setting.

Coyote Time and Jump Buffering

These two are the difference between a jump that feels fair and one that feels broken. Both fix timing problems that players blame on the game, not themselves.

Coyote time gives the player a brief window to still jump right after walking off a ledge, named for the cartoon coyote who hangs in the air before falling. Jump buffering remembers a jump pressed a fraction of a second before landing, so an early press still fires the moment the character touches ground. Both use a small countdown timer.

const COYOTE_TIME = 0.1
const JUMP_BUFFER_TIME = 0.1

var coyote_timer := 0.0
var jump_buffer_timer := 0.0

# Inside _physics_process, near the top:
if is_on_floor():
	coyote_timer = COYOTE_TIME
else:
	coyote_timer -= delta

if Input.is_action_just_pressed("jump"):
	jump_buffer_timer = JUMP_BUFFER_TIME
else:
	jump_buffer_timer -= delta

# Replace the old jump check with this:
if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
	velocity.y = JUMP_VELOCITY
	jump_buffer_timer = 0.0
	coyote_timer = 0.0

The coyote timer refills to full whenever the character is grounded and counts down once airborne, so it stays positive for 0.1 seconds after leaving a ledge. The buffer timer works the same way for the jump press. The jump fires only when both are still positive, then resets both so it cannot double-trigger. A tenth of a second is invisible to the eye but forgiving enough that mistimed jumps almost disappear.

Variable Jump Height and Faster Falling

Godot 4 2D pixel platformer gameplay with characters, collectibles, and platforms
Weighty jumps and precise landings are what make a finished platformer feel good to play.

A jump that always reaches the same height feels robotic. Variable jump height lets a quick tap produce a short hop and a held button produce a full jump. The trick is small: if the player releases the button while still rising, cut the leftover upward speed.

# Variable jump height: shorten the hop on early release
if Input.is_action_just_released("jump") and velocity.y < 0.0:
	velocity.y *= 0.5

The other half of a satisfying arc is falling faster than you rise. Real jumps do not, but games feel better when the descent is snappier than the ascent, which reduces that floaty hang time. Apply a stronger gravity multiplier while the character is moving down:

const FALL_MULTIPLIER = 1.5

# Replace the plain gravity line with this:
if not is_on_floor():
	var multiplier := FALL_MULTIPLIER if velocity.y > 0.0 else 1.0
	velocity += get_gravity() * multiplier * delta

When velocity.y is positive the character is falling, so gravity is multiplied by 1.5 for a heavier drop. On the way up the multiplier is 1.0 for a normal rise. These “unrealistic” tweaks are the core of game feel: physics that would fail a science test but pass the more important test of feeling good in your hands.

One-Way Platforms

One-way platforms let the player jump up through a ledge and land on top of it, a staple of the genre. You do not need code for the basic version. Select the platform’s CollisionShape2D (or the CollisionPolygon2D), and in the Inspector enable One Way Collision. The body now passes through from below and collides from above.

Dropping back down through one is the part that needs a little logic. The common approach is a “drop-through” input that briefly stops the player from colliding with the platform layer, letting them fall through before collision re-enables. Set your one-way platforms on their own collision layer so you can toggle just that layer on the player without affecting solid ground.

The Complete Godot 4 2D Platformer Controller

Here is every layer assembled into one script. Attach it to your CharacterBody2D, make sure the three input actions exist, and you have a controller that runs, jumps with forgiveness, and lands with weight.

extends CharacterBody2D

const SPEED = 300.0
const ACCELERATION = 1500.0
const FRICTION = 1800.0
const JUMP_VELOCITY = -400.0

const COYOTE_TIME = 0.1
const JUMP_BUFFER_TIME = 0.1
const FALL_MULTIPLIER = 1.5
const JUMP_CUT = 0.5

var coyote_timer := 0.0
var jump_buffer_timer := 0.0

func _physics_process(delta: float) -> void:
	# Coyote time and jump buffer timers
	if is_on_floor():
		coyote_timer = COYOTE_TIME
	else:
		coyote_timer -= delta

	if Input.is_action_just_pressed("jump"):
		jump_buffer_timer = JUMP_BUFFER_TIME
	else:
		jump_buffer_timer -= delta

	# Gravity, stronger while falling
	if not is_on_floor():
		var multiplier := FALL_MULTIPLIER if velocity.y > 0.0 else 1.0
		velocity += get_gravity() * multiplier * delta

	# Jump if buffered and within the coyote window
	if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
		velocity.y = JUMP_VELOCITY
		jump_buffer_timer = 0.0
		coyote_timer = 0.0

	# Variable jump height on early release
	if Input.is_action_just_released("jump") and velocity.y < 0.0:
		velocity.y *= JUMP_CUT

	# Horizontal movement with acceleration and friction
	var direction := Input.get_axis("move_left", "move_right")
	if direction != 0.0:
		velocity.x = move_toward(velocity.x, direction * SPEED, ACCELERATION * delta)
	else:
		velocity.x = move_toward(velocity.x, 0.0, FRICTION * delta)

	move_and_slide()

Every constant at the top is a tuning knob. Raise SPEED for a faster character, lower JUMP_VELOCITY (more negative) for a higher jump, and adjust FALL_MULTIPLIER to taste. Change one value at a time and playtest, because feel is subjective and numbers that work for a floaty explorer feel wrong for a twitchy speedrunner.

Common Mistakes

Do This

  • Put all movement in _physics_process, not _process, so physics runs at a fixed timestep.
  • Call move_and_slide() once, at the end, after setting velocity.
  • Multiply every rate by delta so movement is frame-rate independent.
  • Add the three input actions in Project Settings before running.
  • Tune acceleration and friction first; they define the character’s feel.

Avoid This

  • Using the Godot 3 move_and_slide(velocity, ...) signature, which no longer exists.
  • Checking is_on_floor() before move_and_slide() has ever run.
  • Using is_action_pressed for the jump, which makes the character fly while held.
  • Forgetting a CollisionShape2D, so the body falls through the world.
  • Snapping velocity.x to full speed instantly, which feels stiff.

Frequently Asked Questions

Why does move_and_slide() not take velocity as an argument in Godot 4?

Godot 4 changed CharacterBody2D so that velocity is a built-in property. You set velocity directly, then call move_and_slide() with no arguments, and it updates velocity automatically on collisions. The Godot 3 style of velocity = move_and_slide(velocity) was removed and will cause an error.

What is coyote time and why do I need it?

Coyote time is a short window, usually about 0.1 seconds, where the player can still jump right after walking off a ledge. It exists because players often press jump a frame or two late, and without it those jumps feel like the game ignored the input. It makes platforming feel fair without making it easier.

How do I make the jump height depend on how long I hold the button?

Give the character a fixed jump velocity on press, then cut the upward velocity when the button is released early: if Input.is_action_just_released("jump") and velocity.y < 0.0: velocity.y *= 0.5. A quick tap gets a short hop because the rise is cut short, while holding lets the full jump play out.

Should platformer movement go in _process or _physics_process?

Use _physics_process. It runs at a fixed timestep, which keeps collision and movement consistent regardless of frame rate. Put visual-only updates like animation in _process if needed, but anything touching velocity, gravity, or move_and_slide belongs in _physics_process.

How do I add one-way platforms in Godot 4?

Select the platform’s CollisionShape2D and enable One Way Collision in the Inspector. The player then passes through from below and lands on top. To drop back down, put one-way platforms on their own collision layer and briefly disable that layer on the player when a drop input is pressed.

Gear for Long Coding Sessions

Tuning a controller means hours of edit, run, feel, repeat. A keyboard that is comfortable to type on, a monitor with room for the editor and the running game side by side, and a mouse that does not wear out your hand all pay off across a long session. Here is a solid dev setup from the deals database, current as of July 2026.

Away from the keyboard, Berry Finds tracks real-time Amazon deals on thousands of everyday products across home, kitchen, beauty, and more so you never overpay on the stuff you buy regularly.

Summary

A good Godot 4 2D platformer controller is built in layers. Start with a CharacterBody2D that walks and jumps, add acceleration and friction with move_toward, then layer on coyote time, jump buffering, variable jump height, and faster falling. Each piece is a few lines, and each one moves the feel from stiff prototype toward a jump players trust. Copy the complete script, wire up your three input actions, and start tuning the constants until it feels like your game.

From here, the natural next steps are animation states, wall jumps, and dashes, all of which build on this same velocity-and-move_and_slide foundation. For the bigger picture on what makes movement satisfying, our guide to game feel and juice goes deep on the polish that turns a working controller into a memorable one. Godot 4 signals then wire that controller into your UI and enemies without hard-coupling the nodes.