DEV Community

saltmire
saltmire

Posted on Originally published at saltmire.github.io

How to add a loading screen with a progress bar in Godot 4

The default get_tree().change_scene_to_file() works fine for small scenes — but once
your scenes grow (packed tilemaps, many enemies, heavy resources), the player will see a
frozen frame before anything appears. The fix is threaded loading with a real progress bar.

How Godot 4 threaded loading works

Godot 4's ResourceLoader can load a scene in a background thread while your loading
screen stays interactive. There are three calls you need:

  • load_threaded_request(path) — queue the load; call once.
  • load_threaded_get_status(path, progress_array) — poll every frame; fills progress_array[0] with a 0.0–1.0 float.
  • load_threaded_get(path) — grab the finished PackedScene when status is THREAD_LOAD_LOADED.

Step 1: build the loading screen scene

Create a new scene with a CanvasLayer root (name it LoadingScreen) and add two
children: a ColorRect that fills the screen and a ProgressBar.

Set the ProgressBar:

  • Min Value: 0, Max Value: 100
  • Anchor it full-width near the bottom, or centered — your call.
  • Show Percentage: on or off, both are fine.

Attach this script to LoadingScreen:

extends CanvasLayer

@export var next_scene_path: String = ""

var _progress := []

func _ready() -> void:
    if next_scene_path.is_empty():
        push_error("LoadingScreen: next_scene_path is not set.")
        return
    ResourceLoader.load_threaded_request(next_scene_path)

func _process(_delta: float) -> void:
    if next_scene_path.is_empty():
        return
    var status := ResourceLoader.load_threaded_get_status(next_scene_path, _progress)
    if not _progress.is_empty():
        $ProgressBar.value = _progress[0] * 100.0
    match status:
        ResourceLoader.THREAD_LOAD_LOADED:
            var scene: PackedScene = ResourceLoader.load_threaded_get(next_scene_path)
            get_tree().change_scene_to_packed(scene)
        ResourceLoader.THREAD_LOAD_FAILED:
            push_error("LoadingScreen: failed to load " + next_scene_path)
Enter fullscreen mode Exit fullscreen mode

Step 2: trigger it from any scene

Anywhere you would have called change_scene_to_file("res://levels/level_2.tscn"), do
this instead:

func _go_to_next_level() -> void:
    var loader: PackedScene = preload("res://ui/LoadingScreen.tscn")
    var screen := loader.instantiate()
    screen.next_scene_path = "res://levels/level_2.tscn"
    get_tree().root.add_child(screen)
Enter fullscreen mode Exit fullscreen mode

The loading screen overlays the current scene, starts loading level_2.tscn in the
background, and swaps out once the load is done. The current scene never freezes.

Step 3: enforce a minimum display time

For small scenes, loading finishes in milliseconds and the progress bar flashes past.
A minimum display time of 0.5–1 second feels less jarring:

const MIN_SHOW_TIME := 0.6

var _elapsed := 0.0
var _loaded_scene: PackedScene = null

func _process(delta: float) -> void:
    _elapsed += delta
    if next_scene_path.is_empty():
        return
    var status := ResourceLoader.load_threaded_get_status(next_scene_path, _progress)
    if not _progress.is_empty():
        $ProgressBar.value = _progress[0] * 100.0
    if status == ResourceLoader.THREAD_LOAD_LOADED and _loaded_scene == null:
        _loaded_scene = ResourceLoader.load_threaded_get(next_scene_path)
    if _loaded_scene != null and _elapsed >= MIN_SHOW_TIME:
        get_tree().change_scene_to_packed(_loaded_scene)
Enter fullscreen mode Exit fullscreen mode

The scene is grabbed as soon as it is ready, but the transition waits until the minimum
time has passed. The progress bar still fills to 100% immediately — only the switch is
delayed.

Step 4: add a fade-out before switching

change_scene_to_packed cuts instantly. To soften it, tween the CanvasLayer modulate
alpha to 0 before switching:

if _loaded_scene != null and _elapsed >= MIN_SHOW_TIME:
    var t := create_tween()
    t.tween_property(self, "modulate:a", 0.0, 0.3)
    await t.finished
    get_tree().change_scene_to_packed(_loaded_scene)
Enter fullscreen mode Exit fullscreen mode

Keep the tween short (0.2–0.4 s). Anything longer kills pacing.

Common gotcha: duplicate load requests

If you call load_threaded_request on a path that is already being loaded (for example,
a player hits a trigger twice), Godot returns ERR_ALREADY_IN_USE. Add a guard so it
does not spam errors:

func _ready() -> void:
    var err := ResourceLoader.load_threaded_request(next_scene_path)
    if err != OK and err != ERR_ALREADY_IN_USE:
        push_error("load_threaded_request failed: " + str(err))
Enter fullscreen mode Exit fullscreen mode

ERR_ALREADY_IN_USE means the load is already queued — load_threaded_get will still
return the resource when it finishes, so no action needed.

The finished loading screen

That is the whole setup: a CanvasLayer overlay, three ResourceLoader calls, an
optional minimum display time, and an optional fade-out. It works for any scene size.
If you want richer transitions on the way in or out — iris wipes, pixelate, dissolve,
or slides — Saltmire Transitions wraps those into a one-line call and handles the
scene-switch timing internally.


If you'd rather drop this in than build it, Saltmire Transitions does it as a ready-made tool: https://saltmire.itch.io/saltmire-transitions-scene-transition-kit-for-godot-4

Originally published at https://saltmire.github.io/godot-4-loading-screen-progress-bar.html

Top comments (0)