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)