Overview
Problem: how do you structure the main shape of a game so you're not duplicating stuff everywhere, keeping systems decoupled, and not leaning on global state too hard?
There's basically infinite ways to structure a project, but there's a handful of patterns I keep seeing (and have used myself), so I wanted to walk through a few of them and talk about what's good and bad about each.
This is about project structure specifically as it relates to how a game handles its main scene, or its levels if it has them. It really comes down to a few ideas:
Game.tscn: loading and unloading map dataGame.tscn: loading and unloading sceneschange_scene()with globals patching it togetherchange_scene()with data passing
The Problem Restated
Say you're making a puzzle game with 5 levels. How do you organize that? You've probably already pictured one of the two structures below.
Main Menu -> Level 1 -> Level 2 -> Level n -> End
Main Menu -> Game -> End
The Data Problem
Now think about data that matters per level. Take a chess puzzle: only the board state matters, and nothing needs to carry over between puzzles.
Now imagine a different puzzle game, still 5 levels, but the player has 5 abilities to solve them with, and every time they finish a level they lose whichever ability they used the most. Now you've got state that has to carry from level to level or the game stops making sense.
Level Representations
There are two main ways to represent a "level":
- The level is saved to disk as level data
- The level is its own unique
level_n.tscnscene file
Level data on disk works a lot better for something like that chess puzzle. The flow ends up being: load the level data, and use it to repopulate a world node that's already sitting in the tree, instead of instantiating a whole new scene per level. That's basically the Game.tscn Approach: Loading & Unloading Map Data section below.
Representing a level as its own scene file works better when levels are more varied and different from each other, think something like Mario. That's mostly what this workshop is actually about.
Game.tscn Approach: Loading & Unloading Map Data
The first way to use a persistent Game.tscn container is to just never swap
child scenes at all. This is the
level data stored on disk approach: you keep one
reusable world node sitting in the tree, and each "level" is just data (a resource,
dictionary, save file, whatever) that gets loaded and handed to that node. The node rebuilds
itself, clears the tilemap, respawns entities, resets whatever state the level needs,
instead of a whole new scene getting instantiated.
func load_level(data: LevelData) -> void:
var current_level_data = data
# load tile map
# place entities
# etc...
func unload_level() -> void:
current_level_data = null
# reset tile map
# queue free entities
# etc...
It's lightweight compared to instantiating a whole new scene per level, since you're reusing the same node structure and just swapping the data driving it. Works especially well when levels share a common shape, like grid puzzles, chess boards, etc... Where it falls apart is the moment your levels stop being structurally similar to each other.
Game.tscn Approach: Loading & Unloading Scenes
This one exists because even though you're switching scenes, there's usually stuff you always want to keep loaded, the HUD, managers, other persistent UI.
The idea is you have a main scene, then load whatever you need into it. That keeps you from leaning too hard on globals, because the stuff that always needs to exist already lives in your main scene and can just get handed in as a dependency to whatever you load, instead of being grabbed through an autoload.
Basically the main scene acts more like a container here. You can load and unload scenes without nuking everything in the tree.
The big downside is that it isn't very lightweight. It can also create tight coupling, since some scenes end up depending on that parent container existing and can't really run on their own anymore.
Code Example
GameController (Node)
World (Node2D)
GUI (Control)
SplashScreenManager (Control) # this is for an example
MusicManager
etc...
Here's roughly how you'd implement this. Nothing here needs to be global: anything that
needs to trigger a scene change just gets a direct reference to the
GameController, either wired up with @export in the editor or
passed in through a setup call when it's instantiated. That keeps every dependency explicit
instead of hidden behind a global lookup.
class_name GameController extends Node
@export var world: Node2D
@export var gui: Control
var current_world: Node2D
var current_gui: Control
func _ready() -> void:
current_gui = $GUI/SplashScreenManager
func change_gui_scene(new_scene: String, delete: bool = true, keep_running: bool = false) -> void:
if current_gui != null:
if delete: current_gui.queue_free() # remove entirely
elif keep_running: current_gui.visible = false # just make it invis
else: gui.remove_child(current_gui) # keep in memory, not running
var new_gui = load(new_scene).instantiate()
gui.add_child(new_gui)
current_gui = new_gui
func change_world(new_scene: String, delete: bool = true, keep_running: bool = false) -> void:
if current_world != null:
if delete: current_world.queue_free() # remove entirely
elif keep_running: current_world.visible = false # just make it invis
else: world.remove_child(current_world) # keep in memory, not running
var new_world = load(new_scene).instantiate()
world.add_child(new_world)
current_world = new_world
change_scene() Without Data
In a perfect world you'd just call change_scene() and let each scene run
entirely on its own, fully decoupled, with no container and no shared state.
Sounds great, but it's rarely realistic. The second any state needs to survive the swap,
plain change_scene() isn't enough, because it throws away the entire previous
tree. You need some way to patch things back together across that gap.
change_scene() With Data
If you've got individual scenes but also need some data to survive between them, there's really 2 ways to pull that off.
Using Globals/Autoloads
For a Game Jam or some quick test project, this is honestly probably your best bet. It leans on a lot of globals, which is usually a bad habit, but it works, and it's dead simple to set up.
You could just make one general Bus global that holds whatever needs to be
shared, or split it into more specific ones like Player. Depends what you
actually need.
# in player.gd
func _ready():
Bus.player = self
# wherever you need player
func hurt():
Bus.player.take_damage(5)
Passing Another Way
This is similar in spirit, but instead of reaching through a global, you use a custom
change_scene(data) function to pass specific data along directly. Keeps your
globals clean, but you still run into the same problem: scenes can't fully run on their own.
The swap function instantiates the new scene, hands it the data directly, then swaps it into
the tree. The receiving scene just needs an entry point like initialize() to
receive it.
# scene_switcher.gd (can be a plain function, doesn't have to be an autoload)
func change_scene_with_data(scene_path: String, data: Dictionary = {}) -> void:
var new_scene = load(scene_path).instantiate()
if new_scene.has_method("initialize"):
new_scene.initialize(data)
get_tree().current_scene.queue_free()
get_tree().root.add_child(new_scene)
get_tree().current_scene = new_scene
# level_2.gd
func initialize(data: Dictionary) -> void:
remaining_abilities = data.get("abilities", [])
score = data.get("score", 0)
# called from wherever the transition happens, e.g. level_1.gd
func _on_level_complete() -> void:
change_scene_with_data("res://level_2.tscn", {
"abilities": remaining_abilities,
"score": score,
})
The tradeoff is whatever triggers the swap needs a reference to
change_scene_with_data() itself (an autoload, or passed in some other way), and
the receiving scene still has to already know what shape of data it's getting.
change_scene() Caveats
Worth pointing out: both change_scene() solutions above run into either a
duplication problem or a global reliance problem.
Each scene probably needs a bunch of the same stuff. An AudioPlayer, for
instance, is probably sitting in every single level. You can mitigate that by wrapping the
duplicated stuff into one tscn and dropping that into each level_n.tscn, but now
you've got an extra layer to deal with. Either way, you're eating some boilerplate every time
you add a new level scene.
Or, instead of dealing with any of that, you just make some of it global (which, again, is generally not great practice!).
That's basically why I lean towards the main Game.tscn solutions above.
Level_n (Node2D)
| Entities (Node)
| AudioManager (Node)
| SpawningManager (Node)
...
Conclusion
If your game has no persistent data and every scene can stand entirely on its own, the simplest and cleanest option is just changing scenes directly. No globals, no container, nothing to manage.
That said, a Game.tscn with everything you need baked into it is probably still
the most sustainable, scalable way to actually build a real game.
So, if you don't have persistent data and each scene can run entirely on its own, just go
with plain change_scene(). No container, no globals. It's the simplest and
cleanest option, but it only works as long as nothing needs to survive the swap.
If you've got some persistent data but nothing needs to keep running across the swap,
change_scene() with data is the move. A Bus style autoload is the
fastest way to wire that up and it's fine for a jam or a prototype. Passing data explicitly
through your own change_scene(data) avoids the global, but scenes still can't
fully stand on their own.
If your levels are structurally similar and can basically be represented as data, go with
the Game.tscn approach that loads and unloads map data. Keep one reusable world
node in the tree and rebuild it from data on disk instead of spinning up a new scene every
time.
And if your levels are structurally different from each other, or you've got stuff that
needs to keep running across the swap like a HUD, music, or managers, that's when you want
the Game.tscn approach that loads and unloads scenes. It's heavier and more
tightly coupled to the container, but it's generally the most sustainable and scalable
option out of all of these.
Disagree with me?
If you think I'm wrong about any of this, tell me! I'm always looking to get better at this stuff, and I'd rather hear it than keep doing something wrong. Reach out through this form.
Rat Haven Studios