Complete Game Projects
42.1 Top-down arena shooter
Extensions: high score saved with ConfigFile, power-ups (Area2D + Resource data), screen shake, particles on death, object pooling for bullets.
Main (Node2D) main.gd
├── Player (CharacterBody2D) player.gd [Sprite2D, CollisionShape2D, Muzzle (Marker2D), ShootTimer]
├── Enemies (Node2D)
├── SpawnTimer (Timer)
└── HUD (CanvasLayer) hud.gd [ScoreLabel, HealthBar, GameOver (Label)]# player.gd
class_name ArenaPlayer
extends CharacterBody2D
signal health_changed(value: int)
signal died
const BulletScene := preload("res://bullet.tscn")
@export var speed := 280.0
@export var max_health := 5
var health := max_health
var can_shoot := true
@onready var muzzle: Marker2D = $Muzzle
@onready var shoot_timer: Timer = $ShootTimer
func _ready() -> void:
add_to_group("player")
shoot_timer.timeout.connect(func(): can_shoot = true)
func _physics_process(_delta: float) -> void:
velocity = Input.get_vector("move_left", "move_right", "move_up", "move_down") * speed
move_and_slide()
look_at(get_global_mouse_position())
if Input.is_action_pressed("shoot") and can_shoot:
shoot()
func shoot() -> void:
can_shoot = false
shoot_timer.start()
var b: Bullet = BulletScene.instantiate()
b.global_transform = muzzle.global_transform
get_tree().current_scene.add_child(b)
func hurt(amount: int) -> void:
health = maxi(health - amount, 0)
health_changed.emit(health)
var tween := create_tween()
tween.tween_property(self, "modulate", Color.RED, 0.05)
tween.tween_property(self, "modulate", Color.WHITE, 0.1)
if health == 0:
died.emit()
set_physics_process(false)
hi
…# bullet.gd (Area2D with CollisionShape2D + VisibleOnScreenNotifier2D)
class_name Bullet
extends Area2D
@export var speed := 900.0
@export var damage := 1
func _ready() -> void:
body_entered.connect(_on_body_entered)
$VisibleOnScreenNotifier2D.screen_exited.connect(queue_free)
func _physics_process(delta: float) -> void:
position += transform.x * speed * delta # transform.x = local "forward" in 2D
func _on_body_entered(body: Node2D) -> void:
if body.has_method("hurt"):
body.hurt(damage)
queue_free()# enemy.gd (CharacterBody2D, group "enemies")
class_name ArenaEnemy
extends CharacterBody2D
signal killed(points: int)
@export var speed := 120.0
@export var health := 3
@export var points := 10
var player: ArenaPlayer
func _ready() -> void:
add_to_group("enemies")
player = get_tree().get_first_node_in_group("player")
func _physics_process(_delta: float) -> void:
if not is_instance_valid(player) or not player.visible:
return
velocity = global_position.direction_to(player.global_position) * speed
move_and_slide()
for i in get_slide_collision_count():
var other := get_slide_collision(i).get_collider()
if other == player:
player.hurt(1)
queue_free()
return
func hurt(amount: int) -> void:
health -= amount
if health <= 0:
killed.emit(points)
queue_free()# main.gd
extends Node2D
const EnemyScene := preload("res://enemy.tscn")
var score := 0
@onready var player: ArenaPlayer = $Player
@onready var hud := $HUD
func _ready() -> void:
$SpawnTimer.timeout.connect(_spawn_enemy)
player.health_changed.connect(hud.set_health)
player.died.connect(_on_player_died)
hud.set_health(player.health)
func _spawn_enemy() -> void:
var e: ArenaEnemy = EnemyScene.instantiate()
var rect := get_viewport().get_visible_rect()
var angle := randf() * TAU
e.global_position = rect.get_center() + Vector2.RIGHT.rotated(angle) * rect.size.length() * 0.6
e.killed.connect(_on_enemy_killed)
$Enemies.add_child(e)
$SpawnTimer.wait_time = maxf(0.3, $SpawnTimer.wait_time * 0.98) # ramp difficulty
func _on_enemy_killed(points: int) -> void:
score += points
hud.set_score(score)
func _on_player_died() -> void:
$SpawnTimer.stop()
get_tree().call_group("enemies", "queue_free")
hud.show_game_over(score)
await get_tree().create_timer(3.0).timeout
get_tree().reload_current_scene()# hud.gd
extends CanvasLayer
func set_health(value: int) -> void:
%HealthBar.value = value
func set_score(value: int) -> void:
%ScoreLabel.text = "Score: %d" % value
func show_game_over(final_score: int) -> void:
%GameOver.text = "GAME OVER\nScore: %d" % final_score
%GameOver.show()42.2 Platformer with collectibles, checkpoints and saves
Key pieces (reusing the controller from §20.3):
In each level's script, place the player at GameState.checkpoint_position in _ready() if it is not zero, otherwise at the level's Start marker. Add a main menu with *Continue* (calls load_save() then changes to current_level) and *New Game*.
# game_state.gd (autoload "GameState")
extends Node
signal coins_changed(total: int)
const SAVE_PATH := "user://platformer_save.json"
var coins := 0:
set(v):
coins = v
coins_changed.emit(coins)
var checkpoint_position := Vector2.ZERO
var current_level := "res://levels/level_01.tscn"
func save() -> void:
var data := {
"version": 1,
"coins": coins,
"level": current_level,
"checkpoint": [checkpoint_position.x, checkpoint_position.y],
}
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
f.store_string(JSON.stringify(data))
func load_save() -> bool:
if not FileAccess.file_exists(SAVE_PATH):
return false
var data = JSON.parse_string(FileAccess.get_file_as_string(SAVE_PATH))
if not data is Dictionary:
return false
coins = int(data.get("coins", 0))
current_level = data.get("level", current_level)
var cp: Array = data.get("checkpoint", [0, 0])
checkpoint_position = Vector2(cp[0], cp[1])
return true# checkpoint.gd (Area2D)
extends Area2D
var activated := false
func _ready() -> void:
body_entered.connect(func(body: Node2D):
if body.is_in_group("player") and not activated:
activated = true
$AnimatedSprite2D.play("active")
GameState.checkpoint_position = global_position
GameState.save())# kill_zone.gd (Area2D at the bottom of the level / on spikes)
extends Area2D
func _ready() -> void:
body_entered.connect(_on_body_entered)
func _on_body_entered(body: Node2D) -> void:
if body.is_in_group("player"):
Engine.time_scale = 0.5
await get_tree().create_timer(0.5, true, false, true).timeout # ignore time scale
Engine.time_scale = 1.0
body.global_position = GameState.checkpoint_position
body.velocity = Vector2.ZERO# level_exit.gd (Area2D)
extends Area2D
@export_file("*.tscn") var next_level: String
func _ready() -> void:
body_entered.connect(func(body):
if body.is_in_group("player"):
GameState.current_level = next_level
GameState.checkpoint_position = Vector2.ZERO
GameState.save()
get_tree().change_scene_to_file.call_deferred(next_level))42.3 3D collect-a-thon (outline)
Third-person CharacterBody3D with SpringArm3D camera (§29.4) and an AnimationTree blend space for idle/walk/run.
Collectible gems: Area3D with a rotating MeshInstance3D (tween rotation with set_loops()), emitting Events.gem_collected.
Moving platforms: AnimatableBody3D driven by an AnimationPlayer (enable *Sync to Physics*).
Enemies: NavigationAgent3D patrol/chase with a node-based state machine.
Timer-based level goal and results screen; best times saved in a ConfigFile.
42.4 Reusable scene transition (autoload scene)
Usage anywhere: Transition.change_scene("res://levels/level_02.tscn").
# transition.gd (autoload scene: CanvasLayer → ColorRect "Fade" covering the screen, mouse_filter IGNORE)
extends CanvasLayer
@onready var fade: ColorRect = $Fade
func _ready() -> void:
layer = 100
fade.modulate.a = 0.0
func change_scene(path: String, duration := 0.4) -> void:
var tween := create_tween()
tween.tween_property(fade, "modulate:a", 1.0, duration)
await tween.finished
get_tree().change_scene_to_file(path)
await get_tree().process_frame
tween = create_tween()
tween.tween_property(fade, "modulate:a", 0.0, duration)