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
diedorreached_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.tscnusesplayer.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 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_pathso 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 = 5Then 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: AudioStreamIn the Inspector, create level_01_config.tres from LevelConfig and set values. Then in level.gd:
@export var config: LevelConfigAnd 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.
| Scene | Good public API examples | Avoid exposing |
|---|---|---|
| Player | died, took_damage(amount), set_max_health(value) | Direct references to UI nodes, scene loading, level reset logic |
| Level | spawn_player(), goal_reached | Score rules, HUD logic, global state |
| GameManager | restart_level(), add_score(amount) | Movement/physics, tilemap editing, animation control |
Putting It Together: A Minimal Wiring Checklist
- Main Menu has an exported
PackedScenefor the first level and loads it on Play. - Each Level has: SpawnPoints, GoalArea, Actors container, and a Level script exposing
spawn_player()andgoal_reached. - GameManager exists in the level, spawns the player, connects to
goal_reachedandplayer.died, and shows win/lose overlays. - Win/Lose overlays emit
restart_pressedandmenu_pressed; GameManager handles the actual restart/menu navigation. - Level parameters are configured via exported variables or a
LevelConfigresource assigned per level.