Part 01 — Getting from one scene to another
You will build: A main menu with a Start button and a game scene with a Back to Menu button, wired with change_scene_to_file. Then you rebuild the same flow as a root scene that never unloads and swaps a child instead, and you keep a music track playing across the swap to prove the two shapes are not equivalent.
The one line everyone starts with#
Make two scenes. Keep them minimal — the point here is the flow, not the content.
res://scenes/main_menu.tscn:
MainMenu (Control)
└── StartButton (Button)
res://scenes/game.tscn:
Game (Node2D)
├── Label (Label) # text: "this is the game"
└── BackButton (Button)
Set res://scenes/main_menu.tscn as the main scene in Project → Project Settings → Application → Run.
Now the script on MainMenu:
extends Control
@onready var start_button: Button = $StartButton
func _ready() -> void:
start_button.pressed.connect(_on_start_pressed)
func _on_start_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
And on Game:
extends Node2D
@onready var back_button: Button = $BackButton
func _ready() -> void:
back_button.pressed.connect(_on_back_pressed)
func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
Press Play. Click Start. Click Back. It works.
Say this part out loud before reading further: that is a correct implementation. For a jam build, a demo, a puzzle game where each level is self-contained and nothing needs to outlive the transition, those two lines are the whole feature. There is no hidden cost you are paying by using them. The rest of this part exists because there is a specific thing they cannot do, and you are about to find out what it is by hitting it.
One habit to keep from the start: change_scene_to_file returns an Error, and most code throws it away.
func _on_start_pressed() -> void:
var err: Error = get_tree().change_scene_to_file("res://scenes/game.tscn")
if err != OK:
push_error("Could not change scene: %s" % err)
You get ERR_CANT_OPEN when the path does not resolve to a loadable PackedScene (a rename, a typo, a file you moved outside the editor) and ERR_CANT_CREATE when the scene loads but will not instantiate. Both of those are silent failures otherwise: the button clicks, nothing happens, and you go looking in the wrong place. Two extra lines buy you a message in the Output panel that names the actual problem.
Now break it#
Add a spawn point to the game. The menu will pick which one, because eventually you want a "Continue" button that drops the player somewhere other than the start.
Give Game a variable and a function:
extends Node2D
@onready var back_button: Button = $BackButton
@onready var label: Label = $Label
var spawn_point: String = "default"
func _ready() -> void:
back_button.pressed.connect(_on_back_pressed)
label.text = "spawned at: %s" % spawn_point
func set_spawn_point(point: String) -> void:
spawn_point = point
label.text = "spawned at: %s" % spawn_point
func _on_back_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
Now write the obvious thing in the menu. Change the scene, then configure it:
func _on_start_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
get_tree().current_scene.set_spawn_point("cliff_edge")
Run it. Click Start.
You get an error on the second line — an attempt to call a method on a null value. The line that failed looks completely reasonable. current_scene is a real property. set_spawn_point is a real method on the scene you just loaded. Nothing in that line is misspelled.
Try to work around it and watch the workaround fail too:
func _on_start_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
var game: Node = get_tree().get_first_node_in_group("game")
if game != null:
game.set_spawn_point("cliff_edge")
Now there is no error at all. There is also no spawn point. The if guard turned a crash into silence, which is worse. The label still reads spawned at: default.
What the call actually does#
Read the order of operations, because it explains both failures and it is genuinely surprising:
- The current scene node is removed from the tree immediately. From that moment,
get_tree()called on the outgoing scene returnsnull, andget_tree().current_sceneisnulltoo — the new scene is not available yet. - At the end of the frame, the old scene (already out of the tree) is freed from memory, and then the new scene node is added to the tree.
So the call is half-immediate and half-deferred. The half that happens now is the destruction. The half that happens later is the creation. Your line runs in the gap between them, where the answer to "what is the current scene" is honestly null, because there isn't one.
That is also why get_first_node_in_group("game") found nothing. The game scene has not been instantiated yet. Its _ready has not run. Its nodes are not in any group because they are not in any tree.
The docs are explicit about the escape hatch: to reliably access the new scene, await the scene_changed signal.
func _on_start_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/game.tscn")
await get_tree().scene_changed
get_tree().current_scene.set_spawn_point("cliff_edge")
That runs. The label reads spawned at: cliff_edge.
Notice what you have signed up for, though. The function that awaits is a function in the outgoing scene — the node whose script is running has already been pulled out of the tree, and it will be freed at the end of this frame. You are asking a corpse to finish a sentence. GDScript will do it, because the coroutine's state lives independently of the node, but you now have a resume point in a script attached to a freed object. Add one more line after the await that touches self or any @onready variable and you are reading a freed instance.
There is a second problem, which is design rather than lifetime. The label change happens after the new scene's _ready has already run. For one frame the game exists in its un-configured state. With a spawn point that is a flicker. With something like "which save slot am I loading" it is a whole frame of the wrong world, and any _ready code that depended on the value has already made its decision with the default.
So the honest fix is not the await. The honest fix is to stop needing to touch the new scene from outside it. Put the value somewhere the new scene can read during its own _ready:
# autoload: game_state.gd, registered as GameState
extends Node
var next_spawn_point: String = "default"
# main_menu.gd
func _on_start_pressed() -> void:
GameState.next_spawn_point = "cliff_edge"
get_tree().change_scene_to_file("res://scenes/game.tscn")
# game.gd
func _ready() -> void:
spawn_point = GameState.next_spawn_point
label.text = "spawned at: %s" % spawn_point
Set before, read during. No gap, no await, no frame of wrongness. If you have read the event bus shelf, this is the same idea from a different angle: the thing that survives is the thing that was never inside the scene in the first place.
Keep scene_changed in your head as a fact about the engine — it tells you when the swap finished, which is occasionally what you want for something like re-enabling input. Do not let it become your standard way of setting up a level.
The relatives of change_scene_to_file#
Three more calls on SceneTree are worth knowing before you decide anything.
change_scene_to_packed(packed_scene: PackedScene) takes a scene you already loaded instead of a path. Same deferred order of operations. This is the one to pair with a loading screen, because the expensive disk load happens where you chose to put it rather than inside the call that is supposed to be a transition. Returns OK, ERR_CANT_CREATE, or ERR_INVALID_PARAMETER.
change_scene_to_node(node: Node) takes a node you built or instantiated yourself. Read the warning attached to it carefully: after this call the SceneTree takes ownership of the node and will free it automatically on the next scene change. Any reference you kept becomes invalid. So this does not work:
# don't
var level: Node = level_scene.instantiate()
get_tree().change_scene_to_node(level)
# ... some time later, after another scene change ...
level.do_something() # previously freed instance
Keeping a handle to a node the tree owns is a pattern that works right up until the second transition, which is exactly late enough to survive testing.
reload_current_scene() replaces the current scene with a fresh instance of its original PackedScene. This is the correct wiring for a Restart button, and the reason is not convenience — it is that the button stops caring which level it is on. A restart that hardcodes "res://scenes/level_1.tscn" is a bug waiting for level 2:
func _on_restart_pressed() -> void:
get_tree().reload_current_scene()
It returns ERR_UNCONFIGURED if there is no current scene at all, which is a state you can reach in the second half of this part — worth remembering when you get there.
The other shape: a root that never leaves#
Here is the thing the one-liner cannot do. Add music to the menu.
Put an AudioStreamPlayer under MainMenu, give it a stream, tick Autoplay. Press Play. Music. Click Start.
Silence.
Of course — the menu scene was removed from the tree and freed at the end of that frame, and the audio player was a child of it. There is no version of change_scene_to_file that keeps it. The whole scene goes.
The usual first reaction is "make the music player an autoload", and that does work for music specifically. But follow the thought further. The transition overlay you will want in a minute has the same problem. So does the pause menu from the next part, and the settings holder, and anything that draws on top of everything else. Solve it one autoload at a time and you end up with a scattered pile of singletons that each have to reach into the current scene to know what is going on.
The alternative is to change what "the scene" means. Instead of the tree's current scene being the level, make it a small permanent root that holds the level:
Main (Node)
├── MusicPlayer (AudioStreamPlayer) # autoplay on
├── CurrentScene (Node) # empty container
└── Transition (CanvasLayer)
└── Fade (ColorRect)
Set res://scenes/main.tscn as the main scene now. The menu and the game become things you instantiate into CurrentScene, not things the tree swaps wholesale.
main.gd:
extends Node
const MENU_SCENE: String = "res://scenes/main_menu.tscn"
const GAME_SCENE: String = "res://scenes/game.tscn"
@onready var current_scene_holder: Node = $CurrentScene
func _ready() -> void:
swap_to(MENU_SCENE)
func swap_to(path: String) -> void:
for child in current_scene_holder.get_children():
child.queue_free()
var packed: PackedScene = load(path)
if packed == null:
push_error("Could not load scene: %s" % path)
return
var instance: Node = packed.instantiate()
current_scene_holder.add_child(instance)
Two details in there are load-bearing.
queue_free() does not remove the child immediately — it marks it for deletion at the end of the frame. So for the rest of this frame, current_scene_holder has both the old child and the new one. If anything in the new scene walks the tree looking for siblings, or counts children, or grabs a group member, it can find the outgoing scene. This is the same class of problem as the trap earlier, and it has a direct fix when it bites you:
for child in current_scene_holder.get_children():
current_scene_holder.remove_child(child)
child.queue_free()
remove_child takes it out of the tree right now; queue_free still handles the memory. Use the two-line version when you find yourself debugging a phantom sibling, and understand why you needed it rather than pasting it in defensively.
The second detail: load(path) blocks. On a small scene you will never notice. On a real level with textures and audio, that block is a visible hitch at exactly the moment the player is watching. That is the problem change_scene_to_packed and threaded loading exist to solve, and it is a whole topic of its own — for now, know that the blocking is there and that you have not hidden it by moving it.
Now wire the menu and the game to talk to Main instead of to the tree. The direct version:
# main_menu.gd
extends Control
@onready var start_button: Button = $StartButton
func _ready() -> void:
start_button.pressed.connect(_on_start_pressed)
func _on_start_pressed() -> void:
var main: Node = get_tree().current_scene
main.swap_to(main.GAME_SCENE)
Run it. The menu appears. Click Start. The game appears — and the music keeps playing, because the AudioStreamPlayer was never inside the thing that got swapped.
That is the payoff, and it is worth sitting with for a moment. Nothing about the music player is clever. It did not need to know a transition happened. It did not need to be an autoload with an "is this a scene change or a real stop" flag. It lives above the swap, and the swap cannot reach it.
That get_tree().current_scene is a smell#
The menu now knows it is running underneath a node called Main that has a swap_to method. Reach into a parent by type and you have coupled the child to its parent, which means you cannot run the menu scene alone, and you cannot reuse it anywhere else.
The usual fix is a signal going up instead of a call going down:
# main_menu.gd
extends Control
signal start_requested
@onready var start_button: Button = $StartButton
func _ready() -> void:
start_button.pressed.connect(_on_start_pressed)
func _on_start_pressed() -> void:
start_requested.emit()
# main.gd — inside swap_to, after add_child
if instance.has_signal("start_requested"):
instance.start_requested.connect(_on_start_requested)
if instance.has_signal("exit_requested"):
instance.exit_requested.connect(_on_exit_requested)
func _on_start_requested() -> void:
swap_to(GAME_SCENE)
func _on_exit_requested() -> void:
swap_to(MENU_SCENE)
The menu now announces what the player wants and has no opinion about what happens next. Main decides. You can run main_menu.tscn on its own with F6 and clicking Start does nothing instead of crashing, which is the right kind of nothing.
The has_signal checks are a stopgap — string-keyed and unchecked by the compiler. Once you have more than two screens, the cleaner move is a shared base class:
# scenes/screen.gd
class_name Screen
extends Node
signal transition_requested(scene_path: String)
Then every screen extends Screen, emits transition_requested.emit(GAME_SCENE), and Main connects one signal with no string lookups. That is a refactor to make when the pain arrives, not before.
The trade, stated honestly#
Both shapes are correct. They cost different things.
change_scene_to_file costs you continuity. Nothing survives the transition unless it is an autoload. Every persistent thing has to become a singleton, and singletons that need to know about the current scene get awkward fast.
The root-swap shape costs you the ability to press F6. main.tscn has to be running for anything to work. Open game.tscn, press F6, and you get the level with no music, no transition overlay, and — if anything in it assumes Main exists — an error. That is a real daily cost, paid on every single test run.
You can soften it. Give the level a guard that supplies its own dependencies when it finds itself alone:
func _ready() -> void:
if get_parent() == get_tree().root:
# running this scene directly with F6
_setup_standalone_defaults()
That check works because a scene run on its own is a direct child of the tree's root, whereas under Main it is a grandchild or deeper. It is honest, it is three lines, and it is also a second code path that can rot without you noticing. Whether that is worth it depends on how often you test levels in isolation.
The deciding question is one sentence: does anything need to survive the transition?
If nothing does — no music that continues, no fade that covers the seam, no HUD that persists, no accumulating run state — then change_scene_to_file is not technical debt. It is the smaller solution to the smaller problem, and choosing the bigger one anyway is how projects acquire architecture they never needed. If something does need to survive, one autoload is fine, two is a judgement call, and by three you are building Main badly by accident.
Note that this is the same argument as the one in the state machine shelf, applied to scenes instead of behaviour: the pattern is justified by the problem, and adopting it before you have the problem leaves you maintaining machinery that is not paying for itself.
The fade, which is now nearly free#
Here is where the root-swap shape earns the rest of its keep.
Set up Transition:
Transitionis aCanvasLayer. Set its Layer to something high, like128, so it draws over everything below it.Fadeis aColorRectunder it. Anchors preset Full Rect, colour black.- Set
Fade's Mouse → Filter to Ignore, or the invisible rectangle will swallow every click meant for the menu underneath. This one costs people an afternoon. - Start it transparent: set the ColorRect's
modulatealpha to 0 in the inspector.
Then rewrite swap_to as a coroutine:
extends Node
const MENU_SCENE: String = "res://scenes/main_menu.tscn"
const GAME_SCENE: String = "res://scenes/game.tscn"
const FADE_TIME: float = 0.3
@onready var current_scene_holder: Node = $CurrentScene
@onready var fade: ColorRect = $Transition/Fade
var is_swapping: bool = false
func _ready() -> void:
swap_to(MENU_SCENE)
func swap_to(path: String) -> void:
if is_swapping:
return
is_swapping = true
await _fade_to(1.0)
for child in current_scene_holder.get_children():
current_scene_holder.remove_child(child)
child.queue_free()
var packed: PackedScene = load(path)
if packed == null:
push_error("Could not load scene: %s" % path)
is_swapping = false
return
var instance: Node = packed.instantiate()
if instance.has_signal("start_requested"):
instance.start_requested.connect(_on_start_requested)
if instance.has_signal("exit_requested"):
instance.exit_requested.connect(_on_exit_requested)
current_scene_holder.add_child(instance)
await _fade_to(0.0)
is_swapping = false
func _fade_to(target_alpha: float) -> void:
var tween: Tween = create_tween()
tween.tween_property(fade, "modulate:a", target_alpha, FADE_TIME)
await tween.finished
func _on_start_requested() -> void:
swap_to(GAME_SCENE)
func _on_exit_requested() -> void:
swap_to(MENU_SCENE)
Read what makes this possible. The Transition layer is not inside the thing being swapped, so the fade is uninterrupted across the swap — the overlay never blinks, never re-instantiates, never has to be re-found. create_tween() on Main is safe because Main is not going anywhere; a tween created by a node that gets freed mid-tween is a whole category of confusing bug that you have structurally avoided.
The is_swapping guard exists for a specific reason: the fade takes 0.3 seconds, and a player who double-clicks Start will fire swap_to twice, the second call arriving mid-fade and freeing a scene the first call is still working with. Guard the coroutine or make the button unclickable — but do one of them, because "the transition takes time" means "the player can act during it".
Now try to build this same fade on top of change_scene_to_file and feel the difference. The overlay has to live in an autoload with its own CanvasLayer. It has to fade out, then call change_scene_to_file, then await scene_changed, then fade in — and the await is running in an autoload, which is at least safe from being freed, so it is possible. It is three moving pieces coordinating across a lifetime boundary instead of one function reading top to bottom. That is the trade in its concrete form: not "one is right", but "one puts the seam somewhere you have to keep thinking about".
Checkpoint — definition of done#
- Clicking Start on
main_menu.tscnloads the game scene, and Back returns to the menu, usingchange_scene_to_file. - With a deliberately wrong path in
change_scene_to_file, your error check prints a message in the Output panel instead of the button silently doing nothing. - You reproduced the null failure: a line calling into
get_tree().current_sceneimmediately afterchange_scene_to_fileerrors, and you can point at the reason without rereading this page. - The same setup works when the value is set on an autoload before the change and read in the new scene's
_ready, with noawaitanywhere. -
main.tscnruns as the project's main scene, shows the menu, swaps to the game on Start and back on exit. - Music started by
MusicPlayerunderMainkeeps playing without a gap across at least three swaps in a row. - The screen fades to black, swaps, and fades back in, and the menu's buttons still respond to clicks after the first fade completes (proving the
ColorRectis not eating input). - Double-clicking Start rapidly does not produce an error in the Output panel.
- Running
main_menu.tscndirectly with F6 shows the menu, and clicking Start produces no crash. - You can explain: why does a line written directly after
change_scene_to_fileoperate on nothing, when the line before it clearly succeeded?
Stretch (no instructions)#
- Give
swap_toan optional minimum duration so a very fast load still shows the fade for a readable length of time, instead of flashing. - Add a Restart to the game screen using
reload_current_sceneunder thechange_scene_to_fileshape, then work out what its equivalent is under the root-swap shape —Mainknows the current path, and that is the whole answer. - Make the fade a wipe instead: same structure, a
ColorRectwith a shader or an animatedanchor_left. The transition mechanism should not need to change at all. If it does, the seam is in the wrong place.
If you get stuck#
- "Attempt to call function on a null instance" on the line after
change_scene_to_file→ that is the trap, working as documented.current_sceneis null from the moment the call returns until the end of the frame. Move the setup to an autoload read in the new scene's_ready. - No error, but the configuration did not apply → you wrapped the access in an
if node != nullguard. The guard is passing over a null, not fixing it. Remove the guard temporarily to see the real failure. - "Previously freed instance" some time after a scene change → you stored a reference to a node passed to
change_scene_to_node, or to a child of a scene that has since been swapped. The tree owns those nodes and frees them on the next change. - Music restarts from the top on every swap → the
AudioStreamPlayeris inside the swapped scene, not underMain. Check its position in the scene dock, not its script. - Buttons stop responding after adding the fade overlay → the
ColorRectis intercepting mouse input. Set its Mouse Filter to Ignore. Invisible is not the same as non-interactive. - The fade covers nothing / the game draws on top of it → the
Transitionnode is not aCanvasLayer, or its Layer value is lower than something else drawing above it. Raise it. - Old scene visible for a frame under the new one →
queue_free()defers removal to end of frame. Callremove_child()first, thenqueue_free(). swap_toerrors on a second rapid click → two coroutines are running against the same holder. Add theis_swappingguard or disable the button before awaiting.reload_current_scene()returnsERR_UNCONFIGURED→ under the root-swap shape the tree's current scene isMain, and reloading it is almost never what you meant. Track the path inMainand callswap_toon it.- F6 on a level scene errors immediately → the level is reaching for something that only exists under
Main. Either give it the standalone guard or accept that levels are only run throughmain.tscnand stop pressing F6 on them. - The swap has a visible hitch on a larger scene →
load()blocks the main thread. That is the real cost, not the fade. Threaded loading with a real loading screen is the fix, and it is a bigger topic than this page.
Next: Part 02 — Pausing, and the menu that pauses itself. Read it once the swap works, because pausing is where the tree stops cooperating in a way that looks exactly like your code being broken.