Published on

Godot Download Manager

Authors
  • avatar
    Name
    @Mapdu
    Indie GameSoftware Engineering
    Twitter

A threaded, node-based download manager for Godot 4.2+ with intelligent caching, sequential group downloads, and a runtime singleton API.

Table of Contents


Features

FeatureDescription
Node-based setupConfigure entirely in the Inspector — no boilerplate code required
Multi-threadedConcurrent downloads via configurable thread pool (1–16 threads)
Sequential groupsGroups download one at a time; progress bar resets between groups
Smart cachingFiles matching expected size + SHA-256 hash are skipped instantly — no network request
Update checkPre-flight cache check detects whether downloads are needed before starting
HTTP / HTTPSFull TLS support via Godot's built-in HTTPClient
Dual manifest formatPlain JSON array or versioned {version, files} object
Signal-driven UIPlugin emits signals only — you build the UI however you want
Runtime APIDLClient autoload singleton for on-demand downloads during gameplay
Retry supportFailed downloads are retried up to 3 times with exponential backoff
Redirect followingAutomatically follows HTTP 301, 302, 307, and 308 redirects (up to 5 hops)
Hash verificationPost-download SHA-256 integrity check with automatic cleanup on mismatch
Partial file cleanupIncomplete or failed downloads are deleted automatically before retry or error
Timeout handlingConfigurable timeouts for connection (10 s), request (10 s), and data (10 s)
Auto directory creationMissing directories are created automatically before writing

Installation

  1. Copy the addons/download_manager/ folder into your Godot project.
  2. Open Project > Project Settings > Plugins.
  3. Enable Godot Download Manager.

The plugin registers a DLClient autoload singleton automatically.


Quick Start

Step 1 — Add nodes

YourLoadingScene
├── DownloadProgressOrchestrator
│   ├── DownloadGroupGroup: "Sound Effects"
│   └── DownloadGroupGroup: "Music"
└── CanvasLayer
    └── (your UI: ProgressBar, Labels, Button,)

Step 2 — Configure in the Inspector

Set base_folder, max_threads, and auto_start on the DownloadProgress node. Set group_name, subfolder, and source on each DownloadGroup child.

Step 3 — Connect signals

@onready var dl: DownloadProgress = $DownloadProgress

func _ready() -> void:
    dl.check_completed.connect(_on_check)
    dl.group_progress.connect(_on_progress)
    dl.all_completed.connect(_on_done)

func _on_check(needs_download: bool, bytes: int) -> void:
    if needs_download:
        $UpdateButton.visible = true           # prompt the player
    # else: all_completed fires automatically

func _on_progress(gname: String, idx: int, count: int, dl_bytes: int, total: int) -> void:
    $Bar.value = float(dl_bytes) / total * 100.0 if total > 0 else 0

func _on_done(success: bool) -> void:
    get_tree().change_scene_to_file("res://main_menu.tscn")

DownloadProgress Node

The central orchestrator. Add DownloadGroup children to define what to download.

Exported Properties

PropertyTypeDefaultDescription
base_folderString"user://downloads/"Root directory for all downloaded files. Each group appends its own subfolder.
max_threadsint (1–16)8Number of concurrent download threads per group.
auto_startboolfalseWhen true, downloads begin immediately on _ready(). When false, a cache check runs first and emits check_completed so you can prompt the user before calling start().

Signals

SignalParametersWhen emitted
startedAfter start() is called and the first group begins.
check_completedneeds_download: bool, download_bytes: intAfter the background cache check finishes (only when auto_start is false). If needs_download is false, all_completed(true) follows immediately.
group_progressgroup_name: String, group_index: int, group_count: int, downloaded: int, total: intEvery frame while a group is downloading. downloaded and total are in bytes.
group_completedgroup_name: String, success: boolWhen every task in a group has reached a terminal state (DONE or ERROR).
file_startedfile_name: StringWhen a worker thread begins downloading a file. file_name is the basename only (no directory).
all_completedsuccess: boolWhen all groups have finished. true if zero errors occurred. Also emitted immediately when the cache check determines no downloads are needed.

group_progress Parameters Detail

ParameterDescription
group_nameDisplay name of the currently active group.
group_indexZero-based index of the current group.
group_countTotal number of groups.
downloadedCumulative bytes downloaded so far in the current group.
totalCumulative expected total bytes for the current group.

Methods

MethodReturnsDescription
check()voidRuns a background cache check for all groups. Emits check_completed when done. Called automatically when auto_start is false.
start()voidBegins downloading. Call after check_completed(true, …) or directly if auto_start is true. Can also be called after completion to retry.
get_current_group_name()StringDisplay name of the group currently being downloaded.
get_current_group_index()intZero-based index of the current group.
get_group(index)DownloadGroupReturns the DownloadGroup at the given index, or null if out of range.
get_group_count()intTotal number of DownloadGroup children.
is_running()booltrue while resolving manifests or downloading.

DownloadGroup Node

Represents a batch of files described by a JSON manifest. Must be a direct child of a DownloadProgress node.

Exported Properties

PropertyTypeDefaultDescription
group_nameString""Human-readable name shown in signals and UI. Falls back to the node name if left empty.
subfolderString""Subdirectory relative to the parent's base_folder. Supports nested paths like "audio/sfx" or "models/characters".
sourceString""URL or local file path pointing to a JSON manifest. See Source Types.

Methods

MethodReturnsDescription
get_display_name()StringReturns group_name if set, otherwise the node name.
get_aggregate_progress()Dictionary{"dl": int, "total": int, "done_count": int, "error_count": int, "total_count": int} — cumulative progress across all tasks.
is_finished()booltrue when every task has reached a terminal state.
has_errors()booltrue if any task ended in ERROR.
get_active_files()PackedStringArrayBasenames of files currently in the DOWNLOADING state.
reset()voidClears all tasks and resolution state.

Editor Warnings

The node displays configuration warnings in the editor when:

  • source is empty.
  • The node is not a child of a DownloadProgress node.

DLClient Singleton

A globally accessible autoload for on-demand downloads during gameplay. Registered automatically as DLClient when the plugin is enabled.

Use this when you need to download files outside of a loading screen — for example, downloading a new map while the player is already in-game.

Properties

PropertyTypeDefaultDescription
max_threadsint4Number of concurrent download threads. Set before the first download call.

Methods

MethodReturnsDescription
download_file(url, save_path, expected_size?, expected_hash?)intDownloads a single file. Returns a unique download ID for tracking. expected_size and expected_hash are optional and enable caching.
download_json(source, save_dir)intDownloads all files listed in a JSON manifest. source can be a URL or local path. Returns a unique download ID.
get_progress(id)DictionaryReturns {"dl": int, "total": int, "done": bool} for the given download ID.
is_file_downloaded(save_path)boolReturns true if a file exists at the given path.
is_file_valid(save_path, expected_size, expected_hash?)boolReturns true if the file exists, matches the expected size, and optionally matches the SHA-256 hash. Uses the same cache logic as the download worker.

Signals

SignalParametersWhen emitted
download_progressid: int, downloaded: int, total: intEvery frame for each active download.
download_completedid: int, success: boolWhen the download (single file or JSON group) finishes.

Example — Single file

var id: int = DLClient.download_file(
	"https://cdn.example.com/maps/arena.bin",
	"user://maps/arena.bin",
	2097152,
	"a1b2c3d4..."
)

DLClient.download_completed.connect(func(did: int, success: bool) -> void:
	if did == id and success:
		load_map("user://maps/arena.bin")
)

Example — JSON manifest

var id: int = DLClient.download_json(
	"https://cdn.example.com/dlc/pack2.json",
	"user://dlc/pack2/"
)

DLClient.download_progress.connect(func(did: int, dl: int, total: int) -> void:
	if did == id and total > 0:
		print("%.0f%%" % [float(dl) / total * 100])
)

DLClient.download_completed.connect(func(did: int, success: bool) -> void:
	if did == id:
		print("DLC: " + ("OK" if success else "Failed"))
)

Example — Polling progress manually

# Call anytime to check status
var info: Dictionary = DLClient.get_progress(some_id)
print(info)  # {"dl": 524288, "total": 1048576, "done": false}

Example — Check if file exists

if DLClient.is_file_downloaded("user://maps/arena.bin"):
	load_map("user://maps/arena.bin")
else:
	DLClient.download_file("https://cdn.example.com/maps/arena.bin", "user://maps/arena.bin")

Example — Validate file integrity

# Check if the cached file matches expected size and hash before using it
if DLClient.is_file_valid("user://maps/arena.bin", 2097152, "a1b2c3d4..."):
	load_map("user://maps/arena.bin")
else:
	# File missing, wrong size, or corrupted — re-download
	DLClient.download_file(
		"https://cdn.example.com/maps/arena.bin",
		"user://maps/arena.bin",
		2097152,
		"a1b2c3d4..."
	)

JSON Manifest Format

The plugin auto-detects two formats.

Format 1 — Plain array

[
	{"url": "https://cdn.example.com/audio/bgm.mp3", "size": 5938337, "hash": "c39d..."},
	{"url": "https://cdn.example.com/audio/click.mp3", "size": 3632}
]

Format 2 — Versioned object

{
	"version": 1,
	"files": [
		{"url": "https://cdn.example.com/audio/bgm.mp3", "size": 5938337, "hash": "c39d..."},
		{"url": "https://cdn.example.com/audio/click.mp3", "size": 3632, "hash": "82dc..."}
	]
}

Note: The version field is metadata for your own tracking. The plugin does not use it for update detection — cache validation is always file-based (size + hash).

File Entry Fields

FieldTypeRequiredDescription
urlStringYesFull URL of the file to download.
pathStringNoCustom filename (or relative path) for the saved file. Defaults to the last segment of the URL.
sizeintNoExpected file size in bytes. Enables cache validation and accurate progress reporting.
hashStringNoSHA-256 hex digest. Enables content integrity verification and cache validation.

Cache Behavior by Field Combination

sizehashBehavior
SetSetBest. Cache validates both size and hash — skips download with no network request.
SetCache validates size only. No integrity check.
No caching. File is always re-downloaded.

Note: The filename on disk defaults to the last segment of the URL unless a path field is specified in the manifest entry.


Cache System

The plugin checks the local cache before making any network connection. This means re-running a fully cached download completes in milliseconds.

How it works

  1. Does the file exist at the expected save_path?
  2. Does the file size on disk match expected_size?
  3. (Optional) Does the SHA-256 hash match expected_hash?

If all checks pass, the task is marked DONE immediately — no HTTP request is made.

When cache is checked

ContextWhen
DownloadProgress with auto_start = falseDuring check() — runs in a background thread for all groups simultaneously.
DownloadProgress with auto_start = truePer-task, inside each worker thread before connecting.
DLClientPer-task, inside each worker thread before connecting.

Update Check Flow

When auto_start is false (the default), the plugin runs a pre-flight cache check:

_ready()
check() — resolves all manifests, checks cache (background thread)
  ├─ All files cached
  │    → check_completed(false, 0)
  │    → all_completed(true)
  │    → Enter game immediately
  └─ Some files need downloading
check_completed(true, download_bytes)
Show update prompt to user
User presses button → start()
Download begins

When auto_start is true, start() is called directly — no check, no prompt.


Source Types

The source property on DownloadGroup (and the source parameter of DLClient.download_json()) accepts two kinds of values:

TypeExampleResolution
Local fileres://manifests/core.json, user://cache/manifest.jsonRead synchronously on the main thread.
Remote URLhttps://cdn.example.com/manifest.jsonFetched in a background thread via HTTPClient.

Directory Structure

Given this node configuration:

DownloadProgress (base_folder = "user://game_data/")
├── DownloadGroup (subfolder = "audio/sfx")2 files
├── DownloadGroup (subfolder = "audio/music")1 file
└── DownloadGroup (subfolder = "models")3 files

Downloaded files are saved to:

user://game_data/
├── audio/
│   ├── sfx/
│   │   ├── click.mp3
│   │   └── hover.mp3
│   └── music/
│       └── bgm.mp3
└── models/
	├── character.glb
	├── weapon.glb
	└── environment.glb

Directories are created automatically if they don't exist.


Workflow Diagrams

auto_start = true — Immediate download

start()
Group 1: Resolve manifest → Enqueue tasks → Thread pool downloads
  ├── group_progress (every frame)
  ├── file_started (per file)
Group 1 finished
  ├── group_completed
Group 2: ResolveEnqueueDownload
  ├── group_progress
Group 2 finished → group_completed
All groups done → all_completed

auto_start = false — Check then prompt

check()                       ← runs automatically on _ready()
Background thread: resolve all manifests, check cache for every file
  ├── All cached → check_completed(false, 0)all_completed(true)
  └── Needs download → check_completed(true, total_bytes)
                     User clicks "Update"
                       start() → same flow as auto_start=true

Timeout Configuration

Timeouts are defined as constants in the core modules.

All timeout constants are consistent across DownloadWorker and HttpUtil.

ConstantLocationValueDescription
CONNECT_TIMEOUT_MSHttpUtil10 000 msMaximum time to establish a TCP/TLS connection.
REQUEST_TIMEOUT_MSHttpUtil10 000 msMaximum time waiting for the server to begin responding after the request is sent.
DATA_TIMEOUT_MSHttpUtil10 000 msMaximum time without receiving any data during body transfer.
MAX_RETRIESDownloadWorker3Number of download attempts before giving up.
MAX_REDIRECTSDownloadWorker5Maximum consecutive HTTP redirects before failing.
RETRY_BASE_DELAY_MSDownloadWorker1 000 msBase delay between retries. Multiplied by attempt number (1 s, 2 s, 3 s).
PROGRESS_INTERVAL_MSDownloadWorker100 msMinimum interval between progress signal emissions.

Plugin Architecture

File Structure

addons/download_manager/
├── plugin.cfg                          # Plugin metadata (name, version, entry script)
├── plugin.gd                           # EditorPlugin — registers DLClient autoload
├── download_client.gd                  # Autoload singleton for runtime downloads
├── core/
│   ├── http_util.gd                    # Shared: URL parsing, JSON parsing, blocking HTTP fetch
│   ├── download_task.gd                # Thread-safe data container for a single file
│   ├── download_worker.gd              # HTTP download logic (runs in worker threads)
│   └── thread_pool.gd                  # Thread pool with semaphore-based queue
├── nodes/
│   ├── download_progress.gd            # Orchestrator node for loading screens
│   └── download_group.gd               # Manifest-backed group of files
└── demo/
    ├── demo_scene.tscn                 # Example scene with UI and download groups
    ├── demo_scene.gd                   # Example script with signal handling
    └── manifests/
        ├── sfx_manifest.json           # Sample SFX manifest
        └── music_manifest.json         # Sample music manifest

Class Reference

ClassExtendsVisibilityDescription
DownloadProgressNodePublicOrchestrates sequential group downloads. Add to your loading scene.
DownloadGroupNodePublicDefines a batch of files from a JSON manifest. Child of DownloadProgress.
DownloadClientNodePublic (autoload as DLClient)Singleton for on-demand runtime downloads.
HttpUtilRefCountedInternalShared URL parsing, JSON parsing, and blocking HTTP fetch.
DownloadTaskRefCountedInternalThread-safe data for one file (url, progress, status).
DownloadWorkerRefCountedInternalStatic HTTP download engine. Runs in worker threads.
DownloadThreadPoolRefCountedInternalManages worker threads with a semaphore-based task queue.

DownloadTask.Status Enum

ValueDescription
PENDINGTask created, not yet started.
DOWNLOADINGCurrently being downloaded by a worker thread.
DONECompleted successfully (or cache hit).
ERRORFailed with an error message.

Examples

Minimal — Auto start, no UI

func _ready() -> void:
    $DownloadProgress.auto_start = true
    $DownloadProgress.all_completed.connect(func(ok: bool) -> void:
        if ok: get_tree().change_scene_to_file("res://game.tscn")
    )

Update prompt with button

@onready var dl: DownloadProgress = $DownloadProgress
@onready var btn: Button = $UpdateButton

func _ready() -> void:
    btn.visible = false
    dl.check_completed.connect(func(needs: bool, bytes: int) -> void:
        if needs:
            btn.text = "Download update (%.1f MB)" % [bytes / 1048576.0]
            btn.visible = true
    )
    dl.all_completed.connect(func(ok: bool) -> void:
        if ok: enter_game()
    )
    btn.pressed.connect(func() -> void:
        btn.visible = false
        dl.start()
    )

Full progress UI

@onready var dl: DownloadProgress = $DownloadProgress
@onready var bar: ProgressBar = $ProgressBar
@onready var title: Label = $Title
@onready var status: Label = $Status

var _file: String = ""

func _ready() -> void:
    dl.file_started.connect(func(f: String) -> void: _file = f)
    dl.group_progress.connect(func(gname: String, idx: int, count: int, downloaded: int, total: int) -> void:
        title.text = "Downloading: %s (%d/%d)" % [gname, idx + 1, count]
        if total > 0:
            bar.value = float(downloaded) / total * 100.0
            status.text = "%s (%.2f / %.2f MB)" % [_file, downloaded / 1048576.0, total / 1048576.0]
    )
    dl.group_completed.connect(func(_n: String, _s: bool) -> void: bar.value = 0)
    dl.all_completed.connect(func(ok: bool) -> void:
        title.text = "Ready!" if ok else "Failed"
        bar.value = 100 if ok else 0
    )
    dl.start()

Per-group file counts

dl.group_progress.connect(func(gname: String, idx: int, count: int, downloaded: int, total: int) -> void:
    var group: DownloadGroup = dl.get_group(idx)
    var agg: Dictionary = group.get_aggregate_progress()
    status.text = "Files: %d/%d | %.2f MB" % [agg.done_count, agg.total_count, downloaded / 1048576.0]
)

Multiple nested subfolders

DownloadProgress (base_folder = "user://content/")
├── DownloadGroup (group_name = "Base",  subfolder = "base",      source = "res://m/base.json")
├── DownloadGroup (group_name = "DLC 1", subfolder = "dlc/pack1", source = "https://cdn.com/dlc1.json")
└── DownloadGroup (group_name = "DLC 2", subfolder = "dlc/pack2", source = "https://cdn.com/dlc2.json")

Runtime download during gameplay

# Player enters a zone that requires a new map
func _on_zone_entered(zone_id: String) -> void:
    var url: String = "https://cdn.example.com/maps/%s.bin" % zone_id
    var path: String = "user://maps/%s.bin" % zone_id
    var id: int = DLClient.download_file(url, path)

    DLClient.download_completed.connect(func(did: int, ok: bool) -> void:
        if did == id and ok:
            _load_zone(path)
    , CONNECT_ONE_SHOT)

Runtime DLC pack from manifest

func _on_buy_dlc(pack_name: String) -> void:
    var id: int = DLClient.download_json(
        "https://cdn.example.com/dlc/%s.json" % pack_name,
        "user://dlc/%s/" % pack_name
    )

    DLClient.download_progress.connect(func(did: int, dl: int, total: int) -> void:
        if did == id and total > 0:
            $DLCProgress.value = float(dl) / total * 100
    )

    DLClient.download_completed.connect(func(did: int, ok: bool) -> void:
        if did == id:
            $DLCProgress.visible = false
            if ok: _activate_dlc(pack_name)
    , CONNECT_ONE_SHOT)

Error Handling

Failed downloads are retried up to 3 times with exponential backoff (1 s, 2 s, 3 s). Partial files are cleaned up between retries. If all retries are exhausted, the task status is set to ERROR with a descriptive message.

ErrorWhen
Connection timeoutTCP/TLS handshake exceeds 10 s.
Request timeoutServer does not respond within 10 s after the request is sent.
Data timeoutNo data received for 10 s during body transfer.
HTTP errorServer returns a non-200, non-redirect status code.
Too many redirectsMore than 5 consecutive redirects.
Hash mismatchPost-download SHA-256 does not match expected_hash (not retried).
File errorCannot create the output file (permissions, disk full).

Detecting errors

DownloadProgress: group_completed(name, false) or all_completed(false) signals indicate errors occurred. Use DownloadGroup.has_errors() for per-group checks.

DLClient: download_completed(id, false) signals a failed download.

Recovering from errors

Most transient errors (timeouts, connection failures) are handled automatically by the retry system. Hash mismatches fail immediately without retry since the data is deterministic.

Call DownloadProgress.start() again after completion — it automatically resets all groups and re-downloads from scratch. All signals are emitted on the main thread via call_deferred, so UI updates are safe without additional synchronization.


Limitations

LimitationDetail
No resumeIf a download is interrupted, the file is re-downloaded from the beginning.
No cancel / pauseThere is no API to cancel or pause an in-progress download.
JSON manifests onlyManifest files must be JSON. CSV, XML, or other formats are not supported.
No per-file signals on DLClientThe DLClient singleton emits aggregate progress per download ID, not per individual file.
@mapdu.dev | Thank you for reading my blog