Free Ebook cover Godot 4 for Beginners: Build a Small 2D Game from Scratch

Godot 4 for Beginners: Build a Small 2D Game from Scratch

New course

10 pages

Level Structure, Game Flow, and Best-Practice Project Organization

Capítulo 9

Estimated reading time: 10 minutes

+ Exercise

From Separate Scenes to a Complete Game Loop

At this point you likely have a playable level, a player, enemies/hazards, and some UI pieces. The next step is to assemble them into a reliable loop that players expect: Main Menu → Level → Win/Lose → Restart (or back to menu). Doing this cleanly is mostly an architecture problem: deciding which scene owns which responsibility, how scenes communicate without becoming tangled, and how to configure levels without hardcoding values in scripts.

Target Responsibilities (High-Level)

  • MainMenu scene: start game, quit, optionally select a level/difficulty.
  • Level scene: environment, spawn points, references to goal/end trigger, and level-specific configuration.
  • Player scene: movement, health interactions, taking damage, playing animations, emitting signals like died or reached_goal.
  • GameManager: tracks run state (playing/won/lost), score/health counters, handles transitions (restart, load next level, return to menu).

The key idea: the Level should not contain player logic, and the Player should not directly change scenes. The GameManager orchestrates flow; other scenes emit signals or call a small API on the manager.

Recommended Project Structure and Naming Conventions

Folder Layout (One Practical Option)

res://scenes/              # .tscn files grouped by feature or type
  ui/
    main_menu.tscn
    game_over.tscn
    win_screen.tscn
  levels/
    level_01.tscn
    level_02.tscn
  actors/
    player.tscn
    enemy_slime.tscn
  managers/
    game_manager.tscn
res://scripts/
  actors/
    player.gd
    enemy_slime.gd
  levels/
    level.gd
  managers/
    game_manager.gd
  ui/
    main_menu.gd
res://resources/
  levels/
    level_config.gd
    level_01_config.tres
res://autoload/            # optional: scripts used as autoload singletons
res://art/
res://audio/

Naming Conventions

  • Scenes: snake_case.tscn (e.g., level_01.tscn, main_menu.tscn)
  • Scripts: match the scene name when attached (e.g., player.tscn uses player.gd)
  • Nodes: PascalCase for readability (e.g., Player, SpawnPoints, HUD, GoalArea)
  • Signals: past tense or event-like (e.g., died, goal_reached, score_changed)

Avoiding Circular Dependencies

Circular dependencies happen when A needs B and B needs A (e.g., Player calls Level to restart, Level calls Player to reset). Prefer one-direction communication:

  • Child → Parent via signals (Player emits died; Level/GameManager listens).
  • Manager → Others via a small public API (GameManager calls level.reset() or reloads the scene).
  • Use groups or exported NodePaths instead of hard-coded get_node("../...") chains.

Step-by-Step: Build the Game Flow Scenes

1) Main Menu Scene

Create a main_menu.tscn (Control-based). It should only decide what to load next. Keep it dumb: no gameplay state, no references to Player/Level nodes.

Example script (res://scripts/ui/main_menu.gd) attached to the root Control:

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

extends Control

@export var first_level_scene: PackedScene

func _on_play_pressed() -> void:
	get_tree().change_scene_to_packed(first_level_scene)

func _on_quit_pressed() -> void:
	get_tree().quit()

In the Inspector, assign first_level_scene to res://scenes/levels/level_01.tscn. Connect your Play/Quit buttons to the script methods.

2) Level Scene Owns Spawn Points and Environment

Your level scene should contain:

  • TileMap / static environment
  • Spawn points (Marker2D nodes)
  • A goal trigger (Area2D) or similar win condition trigger
  • A place to instance the Player (either pre-placed or spawned)

Recommended node structure inside level_01.tscn:

Level01 (Node2D)
  Environment (Node2D)
    TileMap
    ...
  SpawnPoints (Node2D)
    PlayerSpawn (Marker2D)
    EnemySpawn1 (Marker2D)
  GoalArea (Area2D)
  Actors (Node2D)
    Player (instanced or spawned)
    Enemies...

Attach a generic level.gd script to each level root (or inherit from a base Level scene). The Level should expose spawn positions and emit events like goal reached.

extends Node2D
class_name Level

signal goal_reached

@export var player_scene: PackedScene
@export var player_spawn_path: NodePath

@onready var _actors: Node2D = $Actors
@onready var _player_spawn: Marker2D = get_node(player_spawn_path)

func spawn_player() -> Node:
	var player = player_scene.instantiate()
	_actors.add_child(player)
	player.global_position = _player_spawn.global_position
	return player

func _on_goal_area_body_entered(body: Node) -> void:
	if body.is_in_group("player"):
		emit_signal("goal_reached")

Notes:

  • Use player_spawn_path so the Level can be reused even if you rename nodes (you just reassign the path in the Inspector).
  • Put the Player in a "player" group so win checks don’t rely on class names or node names.

3) GameManager Orchestrates the Run

The GameManager is responsible for:

  • Tracking state: playing/won/lost
  • Tracking score and health (or reading health from Player and mirroring it)
  • Listening to Player and Level signals
  • Restarting the level or moving to win/lose screens

You can implement GameManager as:

  • A node inside each level (simple, self-contained), or
  • An autoload singleton (useful if you want persistent meta-progression across scenes)

For a beginner-friendly loop, place game_manager.tscn inside the level scene so it can wire itself to that level’s nodes without global state.

Example node structure inside a level:

Level01 (Node2D)
  GameManager (Node)
  ...

Example game_manager.gd:

extends Node
class_name GameManager

enum RunState { PLAYING, WON, LOST }

@export var win_screen_scene: PackedScene
@export var game_over_scene: PackedScene

var state: RunState = RunState.PLAYING
var score: int = 0

@onready var level: Level = get_parent() as Level
var player: Node = null

func _ready() -> void:
	# Spawn and wire
	player = level.spawn_player()
	# Listen to level events
	level.goal_reached.connect(_on_goal_reached)
	# Listen to player events (you provide these signals in Player)
	if player.has_signal("died"):
		player.connect("died", _on_player_died)
	if player.has_signal("score_changed"):
		player.connect("score_changed", _on_score_changed)

func _on_goal_reached() -> void:
	if state != RunState.PLAYING:
		return
	state = RunState.WON
	_show_win_screen()

func _on_player_died() -> void:
	if state != RunState.PLAYING:
		return
	state = RunState.LOST
	_show_game_over()

func _on_score_changed(new_score: int) -> void:
	score = new_score

func restart_level() -> void:
	get_tree().reload_current_scene()

func _show_win_screen() -> void:
	var ui = win_screen_scene.instantiate()
	get_tree().current_scene.add_child(ui)
	# Expect the UI to emit signals like "restart_pressed" or "menu_pressed"
	if ui.has_signal("restart_pressed"):
		ui.connect("restart_pressed", restart_level)

func _show_game_over() -> void:
	var ui = game_over_scene.instantiate()
	get_tree().current_scene.add_child(ui)
	if ui.has_signal("restart_pressed"):
		ui.connect("restart_pressed", restart_level)

This pattern keeps scene changes and restarts in one place. The Player doesn’t need to know what happens after death; it just emits died.

4) Win/Lose Screens as Lightweight Overlays

Implement win_screen.tscn and game_over.tscn as Control scenes that appear on top of gameplay. They should:

  • Pause gameplay if desired (optional)
  • Offer buttons: Restart, Main Menu
  • Emit signals instead of directly reloading scenes

Example script for both screens:

extends Control

signal restart_pressed
signal menu_pressed

func _on_restart_button_pressed() -> void:
	emit_signal("restart_pressed")

func _on_menu_button_pressed() -> void:
	emit_signal("menu_pressed")

If you want to pause the game while the overlay is visible, you can set get_tree().paused = true when showing it, and ensure the UI nodes have Process Mode set to When Paused. Keep that decision in GameManager so it’s consistent.

Data-Driven Levels: Exported Variables and Resources

Hardcoding level parameters (time limit, number of enemies, score to win, background music) quickly becomes messy. A data-driven approach lets you tweak values per level without editing scripts.

Option A: Exported Variables on the Level

Simple and effective for small projects. In level.gd:

@export var time_limit_seconds: float = 60.0
@export var target_score: int = 10
@export var enemy_count: int = 5

Then the GameManager reads these values from level and configures the run.

Option B: LevelConfig Resource (More Scalable)

Resources are assets that store data. You can create a reusable LevelConfig resource and assign a different .tres file per level.

Create res://resources/levels/level_config.gd:

extends Resource
class_name LevelConfig

@export var time_limit_seconds: float = 60.0
@export var target_score: int = 10
@export var player_max_health: int = 3
@export var music: AudioStream

In the Inspector, create level_01_config.tres from LevelConfig and set values. Then in level.gd:

@export var config: LevelConfig

And in game_manager.gd:

func _ready() -> void:
	player = level.spawn_player()
	if level.config:
		_apply_level_config(level.config)
	# connect signals...

func _apply_level_config(cfg: LevelConfig) -> void:
	# Example: pass max health to player if it supports it
	if player.has_method("set_max_health"):
		player.call("set_max_health", cfg.player_max_health)
	# Example: start music (if you have an AudioStreamPlayer)
	if cfg.music and has_node("../Music"):
		get_node("../Music").stream = cfg.music
		get_node("../Music").play()

This keeps gameplay scripts stable while you iterate on balance by editing resource files.

Small Refactor Pass: Make the Codebase Easier to Maintain

Refactor 1: Replace Node Name Lookups with Exported NodePaths

If you have code like get_node("../HUD") or $"../Player", it breaks when you rearrange nodes. Prefer:

  • @export var hud_path: NodePath
  • @onready var hud = get_node(hud_path)

This makes dependencies explicit in the Inspector and reduces fragile scene coupling.

Refactor 2: Use Signals for Events, Not Direct Calls Across Scenes

Common improvement: instead of Player calling get_tree().reload_current_scene() on death, the Player emits died. GameManager decides what “death” means (reload, show screen, reduce lives, etc.).

Player-side pattern:

signal died

func kill() -> void:
	emit_signal("died")
	queue_free()

Manager-side pattern: connect once in _ready() and handle flow centrally.

Refactor 3: Centralize Game State in One Place

If score is updated in multiple scripts, bugs appear (double counting, UI desync). Pick one source of truth:

  • Either Player owns score and emits score_changed
  • Or GameManager owns score and exposes add_score(amount)

For beginners, GameManager-owned score is often clearer because it matches “orchestrator” responsibility:

var score := 0
signal score_changed(score: int)

func add_score(amount: int) -> void:
	score += amount
	emit_signal("score_changed", score)

Then enemies/collectibles call get_tree().get_first_node_in_group("game_manager") or (better) emit a signal that the manager listens to. If you use groups, add the GameManager node to a "game_manager" group in the Inspector.

Refactor 4: Keep Scene APIs Small and Intentional

A good rule: each scene exposes only a few methods/signals that describe intent, not implementation details.

SceneGood public API examplesAvoid exposing
Playerdied, took_damage(amount), set_max_health(value)Direct references to UI nodes, scene loading, level reset logic
Levelspawn_player(), goal_reachedScore rules, HUD logic, global state
GameManagerrestart_level(), add_score(amount)Movement/physics, tilemap editing, animation control

Putting It Together: A Minimal Wiring Checklist

  • Main Menu has an exported PackedScene for the first level and loads it on Play.
  • Each Level has: SpawnPoints, GoalArea, Actors container, and a Level script exposing spawn_player() and goal_reached.
  • GameManager exists in the level, spawns the player, connects to goal_reached and player.died, and shows win/lose overlays.
  • Win/Lose overlays emit restart_pressed and menu_pressed; GameManager handles the actual restart/menu navigation.
  • Level parameters are configured via exported variables or a LevelConfig resource assigned per level.

Now answer the exercise about the content:

In a clean Godot 4 scene architecture for a small 2D game loop, what is the recommended way to handle the player dying so you avoid tangled dependencies?

You are right! Congratulations, now go to the next page

You missed! Try again.

To avoid circular dependencies, the Player should not change scenes. It emits events (e.g., died), and the GameManager orchestrates the flow by showing overlays and restarting/reloading as needed.

Next chapter

Polish, Testing, and Exporting a Small 2D Godot 4 Game

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.