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 data
  • Game.tscn: loading and unloading scenes
  • change_scene() with globals patching it together
  • change_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":

  1. The level is saved to disk as level data
  2. The level is its own unique level_n.tscn scene 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.