Skip to content
Learni
View all tutorials
Développement de Jeux

How to Create a 2D Platformer Game with Godot in 2026

Lire en français

Introduction

In 2026, Godot 4 is the top choice for indie developers thanks to its free nature, lightweight design, and native 2D/3D performance. This intermediate tutorial walks you step-by-step through creating a complete 2D platformer: a player that jumps and moves fluidly, patrolling enemies, a scoring system, a following camera, and precise collisions. Unlike basic tutorials, we implement advanced mechanics like realistic acceleration (like a skateboard gradually slowing down) and simple enemy AI.

Why this project? It covers 80% of the needs for a marketable prototype, SEO-optimized for Godot searches. At the end, you'll have a playable game in 30 minutes, expandable to Steam. We use GDScript 2.0 for its Python-like simplicity, the visual scene editor, and best practices for scaling to Vulkan rendering. Ready to jump?

Prerequisites

  • Godot 4.3+ installed (download from godotengine.org)
  • Basic GDScript knowledge (variables, signals, _physics_process)
  • Understanding of 2D nodes (CharacterBody2D, TileMap, CollisionShape2D)
  • Optional text editor (VS Code with Godot Tools extension)
  • Estimated time: 45 minutes for a playable prototype

Player script: smooth movement and jumping

player.gd
extends CharacterBody2D

@export var speed: float = 300.0
@export var jump_velocity: float = -450.0
@export var acceleration: float = 1500.0

var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

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

	# Saut double si en l'air (mécanique intermédiaire)
	if Input.is_action_just_pressed("ui_accept") and is_on_floor():
		velocity.y = jump_velocity
	elif Input.is_action_just_pressed("ui_accept") and velocity.y > 0 and get_slide_collision_count() > 0:
		velocity.y = jump_velocity * 0.7

	# Mouvement horizontal avec accélération (réaliste)
	var direction: float = Input.get_axis("ui_left", "ui_right")
	if direction != 0:
		velocity.x = move_toward(velocity.x, direction * speed, acceleration * delta)
	else:
		velocity.x = move_toward(velocity.x, 0, acceleration * delta)

	move_and_slide()

	# Animation flip (à attacher à AnimatedSprite2D)
	if direction > 0:
		scale.x = 1
	elif direction < 0:
		scale.x = -1

This complete script handles gravity synced with project settings, double jump for fluidity (like Celeste), and progressive acceleration to avoid jerky movement. Attach it to a CharacterBody2D with CollisionShape2D and Sprite2D. Pitfall: forgetting gravity * delta leads to unrealistic falls on slow machines.

Set up the player scene

Create a new 'Player.tscn' scene:

  1. Add a root CharacterBody2D.
  2. Children: Sprite2D (import a 32x64px pixel art sprite), CollisionShape2D (RectangleShape2D sized to 20x50px), optional AnimatedSprite2D.
  3. Attach 'player.gd' to the root.
  4. In Project Settings > Input Map, add 'jump' mapped to Space.

Test: F6 to run the scene. The player accelerates/decelerates like in a real platformer. Analogy: like a vector leaning toward the target via move_toward().

Enemy script: patrol and deadly collision

enemy.gd
extends CharacterBody2D

@export var patrol_speed: float = 100.0
@export var patrol_distance: float = 200.0
@export var gravity: float = ProjectSettings.get_setting("physics/2d/default_gravity")

var direction: float = 1.0
var start_position: Vector2

@onready var player: Node2D = get_tree().get_first_node_in_group("player")

func _ready() -> void:
	start_position = global_position

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

	# Patrouille aller-retour
	global_position.x += patrol_speed * direction * delta
	if abs(global_position.x - start_position.x) > patrol_distance:
		direction *= -1
		scale.x *= -1

	# Détection joueur et mort
	if player and global_position.distance_to(player.global_position) < 30:
		player.queue_free()  # Game Over simple

	move_and_slide()

The enemy patrols a fixed distance, flips visually, and kills the player on contact (extensible via signals). Uses @onready for the grouped player. Pitfall: without get_slide_collision_count(), walls block poorly; test with TileMap.

Integrate the enemy into the main scene

  • Create 'Main.tscn': root Node2D.
  • Add TileMap for ground/platforms (create TileSet with collision).
  • Instance Player.tscn at (100, 300).
  • Add Enemy.tscn at (500, 280), group Player as 'player'.
  • Camera2D as child of Player, enabled with drag margins.
Run F5: player jumps, enemy patrols, collision kills. Tip: Use TileMap layers for separate collisions (ground vs walls).

Advanced following camera script

camera.gd
extends Camera2D

@export var follow_speed: float = 5.0
@export var deadzone_margin: float = 0.2

@onready var player: CharacterBody2D = get_parent()

func _physics_process(delta: float) -> void:
	if not player:
		return

	var target_pos: Vector2 = player.global_position

	# Suivi fluide avec deadzone (comme Hollow Knight)
	if abs(global_position.x - target_pos.x) > get_viewport_rect().size.x * deadzone_margin:
		global_position.x = lerp(global_position.x, target_pos.x, follow_speed * delta)
	if abs(global_position.y - target_pos.y) > get_viewport_rect().size.y * deadzone_margin * 0.5:
		global_position.y = lerp(global_position.y, target_pos.y, follow_speed * delta * 0.8)

	# Limites scène (éditez les valeurs)
	global_position.x = clamp(global_position.x, 100, 1800)
	global_position.y = clamp(global_position.y, 100, 600)

The camera smoothly follows with lerp and deadzone for focused gameplay (like Hollow Knight), clamped to borders. Attach to Player. Pitfall: without delta in lerp, stuttering on variable FPS; viewport_rect() auto-scales.

HUD script: dynamic score and lives

hud.gd
extends CanvasLayer

@onready var score_label: Label = $ScoreLabel
@onready var lives_label: Label = $LivesLabel

var score: int = 0
@export var lives: int = 3

signal score_updated(new_score: int)
signal lives_changed(new_lives: int)

func _ready() -> void:
	score_updated.connect(_on_score_updated)
	lives_changed.connect(_on_lives_changed)
	update_ui()

func add_score(points: int) -> void:
	score += points
	score_updated.emit(score)

func lose_life() -> void:
	lives -= 1
	lives_changed.emit(lives)
	if lives <= 0:
		get_tree().reload_current_scene()

func _on_score_updated(new_score: int) -> void:
	score_label.text = "Score: " + str(new_score)

func _on_lives_changed(new_lives: int) -> void:
	lives_label.text = "Vies: " + str(new_lives)

func update_ui() -> void:
	score_label.text = "Score: 0"
	lives_label.text = "Vies: " + str(lives)

CanvasLayer HUD manages score/lives via decoupled signals, resets scene on 0 lives. Add child Labels. Connect from Player (add_score(10) on coin). Pitfall: without CanvasLayer, UI scaling bugs out.

Assemble the complete game and test

In Main.tscn:

  • Add HUD.tscn as root child.
  • Player.connect("collect_coin", hud.add_score) (implement coin signal in player.gd).
  • Export Project > Windows/Desktop.

Test: collect invisible coins via Area2D+signal, lose lives on enemy. Runs at 120 FPS. Analogy: signals like Unity events, avoiding costly polling.

Global game manager script

game_manager.gd
extends Node

@onready var player: Node2D = $Player
@onready var hud: Node2D = $HUD

var high_score: int = 0

func _ready() -> void:
	player.get_node("Area2D").body_entered.connect(_on_enemy_hit)

func _on_enemy_hit(body: Node2D) -> void:
	if body == player:
		hud.lose_life()

func save_high_score() -> void:
	high_score = max(high_score, hud.score)
	var file = FileAccess.open("user://highscore.save", FileAccess.WRITE)
	file.store_32(high_score)

func _notification(what: int) -> void:
	if what == NOTIFICATION_WM_CLOSE_REQUEST:
		save_high_score()

AutoLoad singleton for persistent highscore (user:// cross-platform), hooks enemy hits. Add to autoload. Pitfall: FileAccess without check_exists() crashes on mobile.

Best practices

  • Groups and signals: Always group ("player") and emit signals for decoupling (avoids fragile get_node()).
  • Exports: @export everything tunable in the editor, speeds up iterations x10.
  • Synchronous physics: Use gravity from ProjectSettings, never hardcode.
  • Performance: TileMap for levels, built-in Profiler for FPS drops.
  • Versioning: Git + .godot/ignore to export without binaries.

Common errors to avoid

  • No delta: velocity += gravity without *delta → infinite speed in slow-mo.
  • One-way collision: Forget one_way_collision_margin on TileMap → player gets stuck.
  • Missing autoload: GameManager without Project Settings > Autoload → lost signals.
  • Negative scale without flip_h: Distorted sprite; use scale.x or AnimatedSprite flip_h.

Next steps

  • Official docs: Godot 4 Platformer Demo
  • Advanced: Add shaders for parallax, Navigation2D for AI.
  • Multiplatform: Vulkan export templates for WebGPU.
Check out our Learni Godot Expert courses to monetize your indie games in 2026.