Map Transitions

This is the seventh tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Multiple Maps and adds support for map transitions.
This tutorial uses Godot 4.3, the regular version, but you could also use the .NET version.
Main Node
Currently, we make the map scene immediately replace itself with the next one when it is completed. We're going to improve this by adding an animation to make the transition smoother. This will complicate map transitions, so we'll isolate the map-loading logic from the map scene and put it somewhere else. Specifically, we introduce a new main.gd
script that will take care of loading the next map. This will act as a global main script that we use to manage the entire game. Create the script, make it extend Node
and copy load_next_map()
from map.gd
to it.
extends Node
func load_next_map() -> void:
var current_map_path := get_tree().current_scene.scene_file_path
var split_path := current_map_path.split(".")
var next_map_number := split_path[1].to_int() + 1
split_path[1] = str(next_map_number).pad_zeros(3)
var next_map_path = ".".join(split_path)
if not ResourceLoader.exists(next_map_path):
split_path[1] = "001"
next_map_path = ".".join(split_path)
get_tree().change_scene_to_file(next_map_path)
We'll also take care of deferring the loading of the next map here, by inserting a call_deferred()
call to load_next_map_deferred()
, which contains the actual code to load the map.
func load_next_map() -> void:
load_next_map_deferred.call_deferred()
func load_next_map_deferred() -> void:
var current_map_path := get_tree().current_scene.scene_file_path
Autoload Script
There should only be one instance of the main script and it should exist separate from the currently loaded map. We could add it to the root node of a new scene and always start with that scene, but that would make it harder to run individual maps directly from the editor. To deal with this Godot allows us to register global scripts to be automatically loaded, no matter which scene is played.
Go to the Globals tab of the Project Settings, then to its Autoload tab. Select main.gd
for the Path and set its Node Name to Main
and press + Add. That will register our main script to be automatically loaded when our game starts. The name that we give it is used to access it via code.

When we run the project or a specific scene Godot will create a scene node and attach our script to it, then make it a child of the root node. This happens before the usual scene gets loaded. Both scenes end up as child scenes of the root node. The first child is Main
and the second child is whichever map we started with.
We can now remove load_next_map()
from map.gd
and instead call the main script's version, via the Main€
global variable that we registered.
func _on_detector_validity_changed(valid: bool):
if valid:
valid_detector_count += 1
if valid_detector_count == registered_detector_count:
#load_next_map.call_deferred()
Main€.load_next_map()
else:
valid_detector_count -= 1
#func load_next_map() -> void:
#…
What is the naming convention of globals?
They're nodes, so we use the same convention as for node names, which is CamelCase. This isn't well defined though, you could also consider them variable names, in which case snake_case is more appropriate. But Godot's documentation uses node names, so I do as well.
Map Transition Scene
Moving on to the transition effect, we're creating a dedicated scene for it, to keep it isolated. Add a new scene, naming it map_transition.tscn
, give it a Node
root, and attach a new map_transition.gd
script to it. Give it a play_exit_map()
function that will take care of playing an animation when exiting a map, along with a separate play_enter_map()
function to be played after the next map has been loaded. These functions are stubs for now, doing nothing.
extends Node
func play_exit_map() -> void:
pass
func play_enter_map() -> void:
pass
When a script is registered for auto-loading a scene gets created for it, but we can also register an existing scene to be auto-loaded as well. Do this for map_transition.tscn
, naming it MapTransition
.

From now on our game's root will have three children when loaded: Main
, MapTransition
, and the current map.
Black Screen
We start with the simplest possible transition scene: a black screen. We can create it by adding a ColorRect
to the scene. Let's name it FaceRect
. Set its Color to solid black and its Control › Layout › Transform › Size 400×240 so it covers the entire game window.

It we directly run the transition scene we'll get a black screen as expected. But when we run a map scene we won't see a black screen, even though it should exist. This happens because the transition scene is loaded before the map scene, being an earlier child in the node tree. Thus the black rectangle gets drawn first and then the map gets drawn on top of it, hiding it.
We can use a CanvasLayer
node to manipulate the draw order. Add one to the scene and set its Layer to 2. Make FaceRect
a child of it. That will make the black screen draw after the map, covering it.

Hiding the Transition Scene
We want the transition scene to be ready when needed, but only be visible during a map transition. We could set the scene to be invisible, but we go one step further and will remove it from the node tree entirely. That way it's always ready for use, but exists in a suspended state, not interfering with the rest of the game.
Add a _ready()
function to map_transition.gd
. This is where we will remove it from the node tree. Get the root node via get_tree().root
and keep track of it via a script variable. The root node represents the game window, hence its type is Window
. Call remove_child()
on it, passing it the node itself, by using self
. This must be a deferred call, because we cannot remove a node while it's being activated.
var root_window : Window
func _ready() -> void:
root_window = get_tree().root
root_window.remove_child.call_deferred(self)
Now the transition scene will get removed from the node hierarchy on startup and we won't see it anymore. Add it back to the node tree in play_exit_map()
to show it again, by calling add_child(self)
on the root. We don't defer the call here, assuming that the caller is deferred already.
We cannot use get_tree()
here, because the node isn't part of the tree at this point and thus has no root. That's why we stored the root in a script variable.
func play_exit_map() -> void:
#pass
root_window.add_child(self)
And we remove the node again in play_enter_map()
, when the transition should finish.
func play_enter_map() -> void:
#pass
root_window.remove_child(self)
If we add the transition scene later, is the canvas layer still needed?
No, we could do without the layer, because when adding the scene again it is now a child after the map scene and is drawn on top of it. However, we keep the layer for clarity. You might also decide to start the game with a transition, in which case the layer would still be needed.
Animation
A static black screen isn't a proper transition, it should fade. We'll create the fading animation by adding an AnimationPlayer
node to the scene. It doesn't matter where it sits in the node tree, as it doesn't draw anything itself.

Selecting the animation player should automatically switch the bottom panel to Animation. Click its Animation button and select New... to create a new animation. Use exit_map
for its name.

FaceRect.
Now check out the Color of the FadeRect
node in the Inspector panel. There is now a key icon next to it. The icon has appeared next to all properties that can be animated. Click it to create an animation track for this value. Then set the color's alpha to zero to make it transparent and click the key icon again to apply it to the track at the current time, which should be at zero seconds.

The Animation panel now shows a track for the color of FaceRect
. Drag the vertical timeline indicator to the 1-second mark. Set the color's alpha back to 1 and click the key icon again to apply a keyframe for this color at the set time. Now you can play the animation via the Animation panel to see the black square fade in.
Let's make the animation a bit fancier by adding a simple closing-curtains effect to it. Duplicate the FadeRect
node and name it LeftSideRect
. Reduce its Control › Layout › Transform › Size to 200×240 so it covers the left half of the screen. Then break the link of its Scale via its chain icon, so the X and Y components can be adjusted independently. Animate the scale so its X component goes from 0 to 1.
Duplicate LeftSideRect
and name it RightSideRect
, set its Position X component to 400, and animate its Scale X component so it goes from 0 to −1.
Now create a new enter_map
animation that does the reverse: fade out the black screen and open the curtains. Make sure that the correct animation is active when changing the keyframes. There is also a RESET
option, which represents the original scene state without any animation applied to it.
Incorporating the Transition
To play the animations we have to export an AnimationPlayer
variable to store a reference to the player. Hook it up via the inspector.
extends Node
@export var animation_player : AnimationPlayer
Play the appropriate animation in the exit and enter functions, by calling play()
on the player, passing it the animation name. When exiting, add first and then play, and when exiting play first and then remove.
func play_exit_map() -> void:
root_window.add_child(self)
animation_player.play("exit_map")
func play_enter_map() -> void:
animation_player.play("enter_map")
root_window.remove_child(self)
Now go to main.gd
and call MapTransition€.play_exit_map()
directly before changing the scene in load_next_map_deferred()
.
if not ResourceLoader.exists(next_map_path):
split_path[1] = "001"
next_map_path = ".".join(split_path)
MapTransition€.play_exit_map()
get_tree().change_scene_to_file(next_map_path)
Waiting for the Animation to Finish
We now see the map-exit transition, but the game doesn't wait for it to finish before loading the next map. We can make play_exit_map()
in map_transition.gd
wait until the player finishes playing the current animation by using its animation_finished
signal. Instead of registering a callback for it, we will block the function directly after calling play()
, using the await
keyword. This turns the function into a coroutine, which are functions that can be paused mid-execution, waiting until certain conditions are met before resuming. Godot will take care of postponing and resuming the function at the appropriate time.
func play_exit_map() -> void:
root_window.add_child(self)
animation_player.play("exit_map")
await animation_player.animation_finished
Do this for play_enter_map()
as well, delaying the removal of the scene until after the animation is finished.
func play_enter_map() -> void:
animation_player.play("enter_map")
await animation_player.animation_finished
root_window.remove_child(self)
To make this work we have to also await the end of play_exit_map()
in main.gd
, turning load_next_map_deferred()
into a coroutine as well.
await MapTransition.play_exit_map()
Now that this works we play the enter-map transition, after changing the map. We also await the conclusion of this transition.
await MapTransition.play_exit_map()
get_tree().change_scene_to_file(next_map_path)
await MapTransition.play_enter_map()
Pausing the Game
Let's pause the game while it is transitioning between maps, so the player cannot mess up the completion state after finishing. This can be done by setting get_tree().paused
to true
before the exit transition and back to false
after the enter transition.
get_tree().paused = true
await MapTransition.play_exit_map()
get_tree().change_scene_to_file(next_map_path)
await MapTransition.play_enter_map()
get_tree().paused = false
However, this pauses everything, including the transition animations. To avoid that go to the root node of map_transition.tscn
and set its Node › Process › Mode to Always so it ignores the paused state.
Loading During Transition
We now start by playing the exit animation, then load the next map, and finally play the enter animation. To minimize the delay let's begin loading the next map as soon as the exit animation starts. We can do this by calling ResourceLoader.load_threaded_request()
, passing it the next map path, before calling play_exit_map()
.
if not ResourceLoader.exists(next_map_path):
split_path[1] = "001"
next_map_path = ".".join(split_path)
ResourceLoader.load_threaded_request(next_map_path)
get_tree().paused = true
await MapTransition.play_exit_map()
To take advantage of threaded loading we have to retrieve the resource via ResourceLoader.load_threaded_get()
, passing it the same path. If it has been fully loaded at that point we immediately get the resource, otherwise the game's execution will block until it has been fully loaded, as usual. In this case we get the packed scene data, so have to change the scene by calling change_scene_to_packed()
instead of change_scene_to_file()
.
#get_tree().change_scene_to_file(next_map_path)
get_tree().change_scene_to_packed(
ResourceLoader.load_threaded_get(next_map_path)
)
Finally, because changing the scene is now always delayed we no longer have to defer the call to load_next_map()
itself.
func load_next_map() -> void:
#load_next_map_deferred.call_deferred()
#func load_next_map_deferred() -> void:
var current_map_path := get_tree().current_scene.scene_file_path
We now have simple animations that make transitioning to the next map a smoother experience. We also have a main script that could be used to manage global state that should persist across maps. We'll introduce some of that in a future tutorial.