Is using a Master Scene / Wrapper normal for 3D level transitions?

Godot Version

4.4

Question

Hey everyone,

I’m working on a 3D game in Godot 4 and trying to figure out the best project architecture.

Originally, I believed the standard way to do this was to put the player directly into each individual level scene (like Level1.tscn), and when the level finishes, you just redirect the tree to a loading screen scene, and then load up Level2.tscn. I also thought about putting my HUD and pause menu elements into an Autoload singleton, but I am not sure about that anymore.

Recently, I have learned about an alternative approach called a “Scene Wrapper” or master node setup, which looks like this:

GameRoot (Node)
├── Handles inputs, pause logic, saving
├── CanvasLayer
│ ├── Holds HUD, Pause UI, fade transitions
└── LevelContainer (Node)
└── Spawns and deletes the 3D levels under it

Honestly, this feels a bit weird to me. Having the entire game run inside one master scene where levels just load and unload underneath it feels strange compared to switching scenes entirely

I’m also unsure of how a 3D scene behaves when it is loaded inside a level container like this. Does it cause issues with 3D rendering, positions, lighting, or the WorldEnvironment since it isn’t the absolute root node anymore?

Is the wrapper setup actually how people build real, production-ready games in Godot? Would you recommend using this over the Autoload UI or the direct scene-switching method? If you’ve used this architecture, does it scale well for a narrative game, or are there major pitfalls I should watch out for?

Would love to hear how you guys structure your 3D games. Thanks!

I’m using this similar setup but for a small 3D game for my own learning. I have a player stays on tree and just add/delete levels. eg.

Root

|—Player

|—(add/delete levels here)

Advantage is I don’t need to save/load player’s data when changing levels. The tricky part I find is you’d need to manage the connections between player, menus, camera …etc to those levels correctly since the level is not instanced yet. For example, how to code enemy shooting at player’s head when it doesn’t know the player exists. You can not just choose player’s head as target from editable_children so you’d have to do that using Root’s script, so it’s kind of like autoload in a way.

I am building a game similar in style to the Sokoban warehouse packing game. I needed to be able to design new levels and then play them so I went for a master Designer scene and a master Player scene with the level information loaded on demand, very much like the structure you describe @pupupy .

Here is my Designer master scene structure:-

and here is the Player master scene:-

In Designer mode I select meshes and StaticBody3D and CharacterBody3d scenes from a dictionary and place them to design a level, all these placed nodes are children of ScenarioFolder. When saved the level definition file is essentially a Resource containing an array of these placed object scene names and locations etc.

In Player mode I choose a level to play and it loads all the items in the saved Resource file into the ScenarioFolder. When I complete a level I go back to the main menu scene and choose the next level to play so I don’t have continuity challenges like @gododo mentioned.

The main reason I ended up with this structure is because of the need to be able to design and then play new levels, both me as the coder and also the folks who subsequently play the game. The level definitions and savegame progress files are identical format so I have just one chunk of LoadSave code that is used in both Designer and Player mode.

I am currently working on the Help scenes and It will come as no surprise that my master scene looks like this:-

I have elected to call the help topics Films and I have a library/dictionary where each record has a pointer to a saved level definition, a pointer to a help text file that will be displayed to the user and a pointer to a sort of macro that can demonstrate how each control works by mimicking user input and moving the game objects accordingly. Because this Help master scene has many of the UI and interaction components from Designer and Player mode, the only new code I have needed so far (I haven’t entirely got my head round the macro bit yet) has been around managing the film library. Once you have watched the macro you can then have a go at the controls yourself because you are using real game data and ui.

So… the best architecture is the one that lets you do what you want and still makes sense if you want to extend it.

This looks pretty complicated, and I can barely understand what’s going on. I guess I need more context, but if it works, it works.

The problem I’m facing right now is my scene architecture. Since I haven’t started implementing it yet and I’m still brainstorming, I’m not sure whether I should use a master scene with a level container.

My initial idea was to have a separate Subtitles scene. When my character interacts with an object, that object would pass its dialogue text to the Subtitles scene. Even though the character is the one speaking, it’s actually the object that provides the dialogue.

The part I’m struggling with is how the object should communicate with the Subtitles scene. Subtitles can’t just be a static scene because it contains logic for displaying and animating the text.

The solution I came up with was to make the HUD an Autoload singleton and expose something like:

HUD.show_subtitles("text", duration)

Objects could then call this method whenever they need to display dialogue. However, this only works if the HUD itself is part of the Autoload. If I also use a master scene with a level container, it feels like I’d be duplicating logic.

What would you recommend in this situation?

I have a signal name defined in an autoload file, AddMessage, and many game objects emit messages with this signal. They don’t need to know what happens to the signal after that, my game has no reply concept.

I have a separate scene that is essentially a PanelContainer with a title bar and a VBoxContainer that listens for this signal. On receipt it displays the text of the message by creating a RichTextLabel and adding it as a child to the VBoxContainer. I chose a RichTextLabel so that the parameters of the AddMessage signal could include colours and text emphasis etc. This message scene also has a timer that steadily deletes the top message from the VBoxContainer and when the last message is removed the panel is hidden. When a new message arrives the panel will be shown again.

Being a separate scene I can include it as a child in any scene/ui where I need to receive and display the messages.

The scene definition is this:-

The guts of the MessagePanel code is:-

func _ready() -> void:
	SignalMgr.AddMessage.connect(_on_AddMessage)

func _on_AddMessage(speaker: String, msg: String, severity: SignalMgr.Severity = SignalMgr.Severity.ok) -> void:
	var col: String
	match severity:
		SignalMgr.Severity.ok: col="green"
		SignalMgr.Severity.gamewarn: col="gold"
		SignalMgr.Severity.gamepenalty: col="crimson"
		SignalMgr.Severity.codewarn: col="pink"
		SignalMgr.Severity.codeerror: col="hot_pink"
	var new_message: RichTextLabel = RichTextLabel.new()
	new_message.bbcode_enabled = true
	new_message.fit_content = true
	new_message.text = "[font_size=14][color=" +  col + "]" + speaker + ":[/color][color=white] " + msg + "[/color][/font_size]"
	messages.add_child(new_message)
	while messages.get_child_count() > max_messages:
		RemoveTopMessage()
	message_timer.start()
	show()

func RemoveTopMessage() -> void:
	var msg: Node = messages.get_child(0)
	messages.remove_child(msg)
	msg.queue_free()
	if messages.get_child_count() == 0:
		hide()

func _on_MessageTimer_timeout() -> void:
	if messages.get_child_count() > 0:
		RemoveTopMessage()

In the code for the various game objects messages are emitted like this:-

SignalMgr.AddMessage.emit(first_name, "I'm going to " + posn_desc)

SignalMgr.AddMessage.emit(first_name, "I've arrived")

In action it looks like this:-

This example is mostly my NPCs announcing their intentions so I can understand what’s going on. (One day I will get round to changing the coordinates to proper location names.)

I appreciate that this may well be different to the way you want to interact with your subtitles but hopefully it has given you some ideas of possible ways to link the parts together. In due course if you’re happy to do so I would love to see what you come up with.

i see the vision and never thought of it, if I understood it correctly your flow is like this:
NPC - it sents its intensions like SignalMgr.AddMessage.emit(first_name, “I’ve arrived”)
SignalMgr, receives it and passes it to AddMessage, but is your message panel inside an autoload by default so you added it already, or how does instanciate it itself whenever it need and how you autoload looks like?

Also, I kinda read that using autoload for dislpay purposes and etc is not good practice, and it should only be used for backend ones, like userdata config files and etc, what do you think?

For signals to be able to communicate they have to be able to reference the same pathname to that signal. My SignalMgr autoload is just a list of signal name declarations so that any object easily use them. When an object wants to send a message it calls SignalMgr.AddMessage.emit(message) and if an object wants to receive that signal it registers with SignalMgr.AddMessage.connect(handler_function_name), probably in its _ready() function.

The MessagePanel scene is always present, it is not instantiated on demand. I simply hide() or show() it when needed.

I have mixed feelings about autoloads. Sometimes you need a global variable or function, like user config stuff, but it’s very easy to be lazy with the code structure and create a global variable that would really be better defined in just the objects that are using it. There is no single correct answer :slight_smile:

i see, I got the idea and will quite likely try this approach. Also, could yo give me an advice on handling the pause menu. I am not quite sure where to add this, at first I thought to add this to autoload with unhandled input to listen every esc key, but not so sure, I could put it in game manager in the scene, so what do you think? Also, Is it possible to DM you in a more comfortable way, I think I could learn from you a lot. I have few struggles going on, but not so sure how to approach them, and not so many tutorials on this. If not, no problem

Thank you for those kind words. While I am happy to help, I feel one of the best things about asking in an open forum like this is that everybody gets to see and learn from both the questions and the answers so I would prefer to keep it all public.