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
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 = -1This 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:
- Add a root CharacterBody2D.
- Children: Sprite2D (import a 32x64px pixel art sprite), CollisionShape2D (RectangleShape2D sized to 20x50px), optional AnimatedSprite2D.
- Attach 'player.gd' to the root.
- 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
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.
Advanced following camera script
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
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
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.