Free Tutorial · 2D Platformer

How to Make a Platformer in Godot 4
— Complete Beginner Guide

SScriptSnap/March 2026/16 min read

The 2D platformer is one of the best first projects for any game developer. You get instant feedback — a character that runs, jumps, and interacts with the world. And Godot 4 makes it surprisingly straightforward to build one from scratch, even if you've never written a line of GDScript before.

In this Godot 4 platformer tutorial, you'll build a complete 2D platformer step by step. We'll cover CharacterBody2D setup, velocity-based movement, gravity and jumping, coyote time and jump buffering, animated sprites, TileMap level design, collectibles, basic enemies, and Camera2D follow. Every section includes full GDScript code you can paste directly into your project.

By the end of this guide you'll have a playable platformer with polished mechanics that feel good under the thumbs. Let's get started.

Tutorial

1. Project Setup & Scene Structure

Open Godot 4 and create a new project. Set the renderer to Compatibility (fine for 2D) and pick a project folder. Once the editor loads, create a new 2D Scene and save it as main.tscn.

Now build the player scene. Create a new scene with a CharacterBody2D as the root node. This is Godot 4's physics body designed for player-controlled characters — it handles collisions without needing you to write your own physics. Add three child nodes:

  • Sprite2D (or AnimatedSprite2D) — for the character's visual
  • CollisionShape2D — with a RectangleShape2D or CapsuleShape2D sized to match your sprite
  • Camera2D — we'll configure this later for smooth scrolling

Save this scene as player.tscn. Your tree should look like this:

player.tscn — Scene tree
CharacterBody2D (Player)
├── Sprite2D
├── CollisionShape2D
└── Camera2D

Before writing any code, set up your Input Map. Go to Project → Project Settings → Input Map and add four actions: move_left, move_right, jump, and optionally move_down. Bind them to your preferred keys (arrow keys, WASD, or both).

2. Horizontal Movement with Velocity

Attach a new script to your CharacterBody2D. In Godot 4, CharacterBody2D has a built-in velocity property. You set it each frame, then call move_and_slide() to apply the movement and handle collisions automatically.

player.gd — Basic horizontal movement
extends CharacterBody2D

const SPEED := 300.0

func _physics_process(delta: float) -> void:
    var direction := Input.get_axis("move_left", "move_right")
    velocity.x = direction * SPEED

    move_and_slide()

Input.get_axis() returns a value between -1.0 and 1.0. If the player holds left it's -1, right is 1, and nothing is 0. Multiply by speed and your character slides left and right. The move_and_slide() call resolves any collisions with the world.

For smoother, less "digital" feeling movement you can add acceleration and friction:

player.gd — Smooth movement with acceleration
extends CharacterBody2D

const SPEED := 300.0
const ACCELERATION := 1800.0
const FRICTION := 1200.0

func _physics_process(delta: float) -> void:
    var direction := Input.get_axis("move_left", "move_right")

    if direction != 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()

move_toward() smoothly ramps the velocity up or down each frame. Tweak ACCELERATION and FRICTION until the character feels right. Higher values feel snappier; lower values feel floatier.

3. Gravity & Jumping

A platformer without gravity and jumping is just a sliding box. Let's fix that. Godot exposes a project-wide gravity value in Project Settings → Physics → 2D → Default Gravity (default is 980). You can pull this into your script or define your own constant.

player.gd — Gravity and jumping
extends CharacterBody2D

const SPEED := 300.0
const ACCELERATION := 1800.0
const FRICTION := 1200.0
const JUMP_VELOCITY := -500.0

# Pull gravity from project settings so it stays in sync
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

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

    # Jump
    if Input.is_action_just_pressed("jump") and is_on_floor():
        velocity.y = JUMP_VELOCITY

    # Horizontal movement
    var direction := Input.get_axis("move_left", "move_right")
    if direction != 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()

The key insight: is_on_floor() only returns true after move_and_slide() has been called. That's why we check it at the top of the next frame — the previous frame's move_and_slide() determined whether we landed.

JUMP_VELOCITY is negative because in Godot 2D, Y points down. A negative Y velocity moves the character upward. Set it to something like -500 and adjust to taste. Higher absolute values mean higher jumps.

Variable-Height Jumps

Most great platformers let you control jump height by how long you hold the button. When the player releases jump early, cut the upward velocity:

player.gd — Variable jump height
# Add inside _physics_process, after the jump check:
if Input.is_action_just_released("jump") and velocity.y < 0:
    velocity.y *= 0.5  # Cut upward momentum for short hops

This single line makes a huge difference in how responsive the game feels. Players who tap jump get a short hop; players who hold it get a full arc.

Want to build clean, scalable state machines for your game logic? Our State Machines micro-course teaches the same pattern used in professional Godot projects — perfect for platformer player states.

View Course →
📧

Want more Godot tricks?

Get our free GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines & more.

GDSJoin 500+ Godot devs · No spam · Unsubscribe anytime

4. Coyote Time & Jump Buffering

These two techniques are what separate a "good enough" platformer from one that feels great. Coyote time gives the player a few extra frames to jump after walking off a ledge. Jump buffering registers a jump press slightly before the player lands, so the jump fires the instant they touch the ground.

player.gd — Coyote time & jump buffer
extends CharacterBody2D

const SPEED := 300.0
const ACCELERATION := 1800.0
const FRICTION := 1200.0
const JUMP_VELOCITY := -500.0
const COYOTE_TIME := 0.12        # seconds after leaving ground
const JUMP_BUFFER_TIME := 0.1    # seconds before landing

var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")
var coyote_timer: float = 0.0
var jump_buffer_timer: float = 0.0
var was_on_floor: bool = false

func _physics_process(delta: float) -> void:
    # ── Coyote time ──
    if is_on_floor():
        coyote_timer = COYOTE_TIME
    else:
        coyote_timer -= delta

    # ── Jump buffer ──
    if Input.is_action_just_pressed("jump"):
        jump_buffer_timer = JUMP_BUFFER_TIME
    else:
        jump_buffer_timer -= delta

    # ── Gravity ──
    if not is_on_floor():
        velocity.y += gravity * delta

    # ── Jump (with coyote + buffer) ──
    if jump_buffer_timer > 0.0 and coyote_timer > 0.0:
        velocity.y = JUMP_VELOCITY
        coyote_timer = 0.0       # consume coyote
        jump_buffer_timer = 0.0  # consume buffer

    # ── Variable jump height ──
    if Input.is_action_just_released("jump") and velocity.y < 0:
        velocity.y *= 0.5

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

    was_on_floor = is_on_floor()
    move_and_slide()

Both timers count down every frame. The jump only fires when both timers are still positive — meaning the player either pressed jump recently (buffer) or was on the floor recently (coyote). The values 0.10.15 seconds are common; tweak until it feels forgiving but not sloppy.

These are subtle improvements that players never notice when present but immediately feel when missing. Every polished platformer implements them.

5. Animated Sprites — Idle, Run & Jump

Replace your Sprite2D with an AnimatedSprite2D. In the Inspector, create a new SpriteFrames resource and add three animations: idle, run, and jump. Import your sprite sheet frames into each animation and set the FPS (8–12 works well for pixel art).

player.gd — Sprite animation logic
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D

# Call this at the end of _physics_process
func update_animation(direction: float) -> void:
    # Flip sprite to face movement direction
    if direction != 0:
        sprite.flip_h = direction < 0

    # Pick the right animation
    if not is_on_floor():
        sprite.play("jump")
    elif direction != 0:
        sprite.play("run")
    else:
        sprite.play("idle")

Call update_animation(direction) at the very end of your _physics_process function, after move_and_slide(). The order matters: check is_on_floor() after sliding so you get the correct result.

Pro tip: AnimatedSprite2D.play() is safe to call every frame — it won't restart an animation that's already playing. No need for a guard condition. If you want separate "jump_up" and "fall" animations, split on velocity.y < 0 versus velocity.y >= 0.

Want the complete GDScript reference at your fingertips? Grab our free cheat sheet — 20+ copy-paste snippets for movement, signals, tweens, and more.

Get Cheat Sheet →

6. TileMap Level Design

With the player working, you need a world to run around in. In Godot 4 the TileMap node has been completely overhauled. Add a TileMap to your main scene, then create a TileSet resource in the Inspector.

Here's the workflow:

  • Import your tileset PNG (16x16 or 32x32 pixel tiles work great)
  • In the TileSet editor, create a new atlas source and assign your texture
  • Click tiles to define them — Godot auto-detects grid cells
  • Add a Physics Layer in the TileSet to enable collisions
  • Paint collision shapes on your ground/wall tiles
  • Switch to the TileMap editor tab and start painting your level

The Godot 4 TileMap editor is much improved over Godot 3. You can use terrain painting for auto-tiling (it automatically picks corner and edge tiles for you), or paint manually for full control. For a first platformer, paint a flat ground, some platforms at varying heights, and a few walls to test collisions.

For a deeper dive into Godot's tilemap system, check out our Godot 4 TileMap Tutorial.

7. Collectibles & Coins

Every platformer needs things to collect. Create a new scene with an Area2D root node. Add a CollisionShape2D (circle shape) and an AnimatedSprite2D with a spinning coin animation. The Area2D detects overlaps without blocking movement — perfect for pickups.

coin.gd — Collectible coin
extends Area2D

signal collected

func _ready() -> void:
    body_entered.connect(_on_body_entered)
    $AnimatedSprite2D.play("spin")

func _on_body_entered(body: Node2D) -> void:
    if body is CharacterBody2D:
        collected.emit()
        # Disable collision immediately to prevent double-collection
        set_deferred("monitoring", false)
        # Quick tween animation before removing
        var tween := create_tween()
        tween.tween_property(self, "scale", Vector2.ZERO, 0.15)
        tween.tween_callback(queue_free)

On the player side, you need to track the score. A simple approach is a global autoload script:

game_manager.gd — Autoload for score tracking
extends Node

var score: int = 0

func add_score(amount: int) -> void:
    score += amount
    print("Score: ", score)

Register it as an autoload in Project → Project Settings → Autoload with the name GameManager. Then connect the coin's collected signal in your level script, or call GameManager.add_score(1) directly from the coin script. For more on signal patterns, see our Godot 4 Signal Bus Pattern guide.

Get all our Godot courses in one pack — signals, shaders, state machines, and more. Save 50%+ with the bundle.

Grab the Bundle →

8. Enemy Basics — Simple Patrol

A classic platformer enemy walks back and forth along a platform, turning around when it hits a wall or reaches an edge. Create a new scene with a CharacterBody2D root, add a CollisionShape2D, a AnimatedSprite2D, and two RayCast2D nodes pointing down-left and down-right to detect ledges.

enemy_patrol.gd — Simple patrol enemy
extends CharacterBody2D

const SPEED := 60.0
var direction := 1.0
var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

@onready var ray_left: RayCast2D = $RayLeft    # Points down-left
@onready var ray_right: RayCast2D = $RayRight   # Points down-right
@onready var sprite: AnimatedSprite2D = $AnimatedSprite2D

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

    # Move in current direction
    velocity.x = direction * SPEED

    # Turn around at walls
    if is_on_wall():
        direction *= -1.0

    # Turn around at ledges (ray not colliding = no ground ahead)
    if is_on_floor():
        if direction > 0 and not ray_right.is_colliding():
            direction *= -1.0
        elif direction < 0 and not ray_left.is_colliding():
            direction *= -1.0

    # Flip sprite
    sprite.flip_h = direction < 0
    sprite.play("walk")

    move_and_slide()

Position the RayCast2D nodes so they point slightly past the edge of the enemy's collision shape and downward. Set their target_position to something like Vector2(10, 20) and Vector2(-10, 20). Enable them in the Inspector.

Stomping Enemies

Want Mario-style stomping? Add an Area2D hitbox on top of the enemy and check if the player is falling:

enemy_patrol.gd — Stomp detection
@onready var stomp_area: Area2D = $StompArea

func _ready() -> void:
    stomp_area.body_entered.connect(_on_stomp)

func _on_stomp(body: Node2D) -> void:
    if body is CharacterBody2D and body.velocity.y > 0:
        # Player is falling — count as stomp
        body.velocity.y = -300.0  # Bounce the player up
        die()

func die() -> void:
    set_physics_process(false)
    var tween := create_tween()
    tween.tween_property(sprite, "modulate:a", 0.0, 0.2)
    tween.tween_callback(queue_free)

The StompArea should sit on top of the enemy, using a thin rectangle collision shape. We check body.velocity.y > 0 to confirm the player is actually falling and not just touching from the side.

9. Camera2D Follow

Since we added Camera2D as a child of the player, it already follows the character. But the default behavior is rigid and jarring. A few Inspector tweaks make a big difference:

  • Enable Position Smoothing — set Speed to 5.0 for a gentle lag behind the player
  • Set Drag margins (left/right: 0.2, top/bottom: 0.1) so the camera only moves when the player nears the edge of the viewport
  • Set Limit values to prevent the camera from scrolling past your level boundaries

For more advanced camera behavior (screen shake, look-ahead, cinematic pans) you can control the camera from code:

player.gd — Camera screen shake
@onready var camera: Camera2D = $Camera2D

func screen_shake(intensity: float = 4.0, duration: float = 0.2) -> void:
    var tween := create_tween()
    tween.tween_method(
        func(t: float) -> void:
            camera.offset = Vector2(
                randf_range(-intensity, intensity),
                randf_range(-intensity, intensity)
            ),
        1.0, 0.0, duration
    )
    tween.tween_callback(func() -> void: camera.offset = Vector2.ZERO)

Call screen_shake() when the player lands from a long fall, stomps an enemy, or takes damage. Small screen shakes add a surprising amount of juice to a platformer. For more on create_tween() patterns, see our Godot 4 Tween Tutorial.

10. Tips & Next Steps

You now have a working platformer with movement, gravity, jumping (with coyote time and variable height), animations, a tile-based level, collectibles, patrolling enemies, and a smooth camera. Here are some ideas for where to take it next:

Polish the Feel

  • Add dust particles when landing and wall-sliding — use GPUParticles2D with a tiny burst emission
  • Squash and stretch the sprite on jump and land using a Tween on the scale property
  • Add a subtle parallax background with ParallaxBackground and ParallaxLayer
  • Play sound effects for jumping, landing, collecting coins, and stomping enemies

Add More Mechanics

  • Wall jumping — check is_on_wall() and apply a diagonal velocity
  • Dashing — add a short burst of speed with a cooldown timer
  • Moving platforms — use AnimatableBody2D with an AnimationPlayer or Tween for movement
  • Checkpoints — save the player's position and respawn there on death
  • Health and damage — use a simple HP variable and add invincibility frames after getting hit

Architecture Tips

  • Use a state machine for the player — idle, running, jumping, falling, and wall-sliding all deserve their own state. Check out our State Machines course for the full pattern.
  • Keep your player script focused on physics. Put animation logic, sound, and particles in separate child nodes or components.
  • Use signals to decouple systems — the coin doesn't need to know about the HUD. Emit a signal and let the HUD listen.
  • Save your game with the Godot Resource system — see our Save System tutorial for a clean approach.

The best way to learn is to keep building. Pick one feature from the list above, implement it, and iterate. Every polished indie platformer was once a janky prototype with a single grey box sliding across the screen — exactly where you are right now.

Level Up
Free download
+ weekly tips

Get the Free GDScript Cheat Sheet + Weekly Godot Tips

Join indie devs getting our GDScript Cheat Sheet with 20+ copy-paste snippets for signals, exports, state machines, and more — plus weekly Godot tips and architecture tricks straight to your inbox.

20+ GDScript snippets
Weekly Godot tips
Zero spam, cancel anytime
>
GDSJoin 500+ Godot devs. Unsubscribe in one click.