- Published on
Godot Download Manager
- Authors

- Name
- @Mapdu
- Indie GameSoftware Engineering
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
- Installation
- Quick Start
- DownloadProgress Node
- DownloadGroup Node
- DLClient Singleton
- JSON Manifest Format
- Cache System
- Update Check Flow
- Source Types
- Directory Structure
- Workflow Diagrams
- Timeout Configuration
- Plugin Architecture
- Examples
- Error Handling
- Limitations
Features
| Feature | Description |
|---|---|
| Node-based setup | Configure entirely in the Inspector — no boilerplate code required |
| Multi-threaded | Concurrent downloads via configurable thread pool (1–16 threads) |
| Sequential groups | Groups download one at a time; progress bar resets between groups |
| Smart caching | Files matching expected size + SHA-256 hash are skipped instantly — no network request |
| Update check | Pre-flight cache check detects whether downloads are needed before starting |
| HTTP / HTTPS | Full TLS support via Godot's built-in HTTPClient |
| Dual manifest format | Plain JSON array or versioned {version, files} object |
| Signal-driven UI | Plugin emits signals only — you build the UI however you want |
| Runtime API | DLClient autoload singleton for on-demand downloads during gameplay |
| Retry support | Failed downloads are retried up to 3 times with exponential backoff |
| Redirect following | Automatically follows HTTP 301, 302, 307, and 308 redirects (up to 5 hops) |
| Hash verification | Post-download SHA-256 integrity check with automatic cleanup on mismatch |
| Partial file cleanup | Incomplete or failed downloads are deleted automatically before retry or error |
| Timeout handling | Configurable timeouts for connection (10 s), request (10 s), and data (10 s) |
| Auto directory creation | Missing directories are created automatically before writing |
Installation
- Copy the
addons/download_manager/folder into your Godot project. - Open Project > Project Settings > Plugins.
- Enable Godot Download Manager.
The plugin registers a DLClient autoload singleton automatically.
Quick Start
Step 1 — Add nodes
YourLoadingScene
├── DownloadProgress ← Orchestrator
│ ├── DownloadGroup ← Group: "Sound Effects"
│ └── DownloadGroup ← Group: "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
| Property | Type | Default | Description |
|---|---|---|---|
base_folder | String | "user://downloads/" | Root directory for all downloaded files. Each group appends its own subfolder. |
max_threads | int (1–16) | 8 | Number of concurrent download threads per group. |
auto_start | bool | false | When 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
| Signal | Parameters | When emitted |
|---|---|---|
started | — | After start() is called and the first group begins. |
check_completed | needs_download: bool, download_bytes: int | After the background cache check finishes (only when auto_start is false). If needs_download is false, all_completed(true) follows immediately. |
group_progress | group_name: String, group_index: int, group_count: int, downloaded: int, total: int | Every frame while a group is downloading. downloaded and total are in bytes. |
group_completed | group_name: String, success: bool | When every task in a group has reached a terminal state (DONE or ERROR). |
file_started | file_name: String | When a worker thread begins downloading a file. file_name is the basename only (no directory). |
all_completed | success: bool | When 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
| Parameter | Description |
|---|---|
group_name | Display name of the currently active group. |
group_index | Zero-based index of the current group. |
group_count | Total number of groups. |
downloaded | Cumulative bytes downloaded so far in the current group. |
total | Cumulative expected total bytes for the current group. |
Methods
| Method | Returns | Description |
|---|---|---|
check() | void | Runs a background cache check for all groups. Emits check_completed when done. Called automatically when auto_start is false. |
start() | void | Begins 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() | String | Display name of the group currently being downloaded. |
get_current_group_index() | int | Zero-based index of the current group. |
get_group(index) | DownloadGroup | Returns the DownloadGroup at the given index, or null if out of range. |
get_group_count() | int | Total number of DownloadGroup children. |
is_running() | bool | true 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
| Property | Type | Default | Description |
|---|---|---|---|
group_name | String | "" | Human-readable name shown in signals and UI. Falls back to the node name if left empty. |
subfolder | String | "" | Subdirectory relative to the parent's base_folder. Supports nested paths like "audio/sfx" or "models/characters". |
source | String | "" | URL or local file path pointing to a JSON manifest. See Source Types. |
Methods
| Method | Returns | Description |
|---|---|---|
get_display_name() | String | Returns 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() | bool | true when every task has reached a terminal state. |
has_errors() | bool | true if any task ended in ERROR. |
get_active_files() | PackedStringArray | Basenames of files currently in the DOWNLOADING state. |
reset() | void | Clears all tasks and resolution state. |
Editor Warnings
The node displays configuration warnings in the editor when:
sourceis empty.- The node is not a child of a
DownloadProgressnode.
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
| Property | Type | Default | Description |
|---|---|---|---|
max_threads | int | 4 | Number of concurrent download threads. Set before the first download call. |
Methods
| Method | Returns | Description |
|---|---|---|
download_file(url, save_path, expected_size?, expected_hash?) | int | Downloads 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) | int | Downloads all files listed in a JSON manifest. source can be a URL or local path. Returns a unique download ID. |
get_progress(id) | Dictionary | Returns {"dl": int, "total": int, "done": bool} for the given download ID. |
is_file_downloaded(save_path) | bool | Returns true if a file exists at the given path. |
is_file_valid(save_path, expected_size, expected_hash?) | bool | Returns 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
| Signal | Parameters | When emitted |
|---|---|---|
download_progress | id: int, downloaded: int, total: int | Every frame for each active download. |
download_completed | id: int, success: bool | When 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
versionfield 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
| Field | Type | Required | Description |
|---|---|---|---|
url | String | Yes | Full URL of the file to download. |
path | String | No | Custom filename (or relative path) for the saved file. Defaults to the last segment of the URL. |
size | int | No | Expected file size in bytes. Enables cache validation and accurate progress reporting. |
hash | String | No | SHA-256 hex digest. Enables content integrity verification and cache validation. |
Cache Behavior by Field Combination
size | hash | Behavior |
|---|---|---|
| Set | Set | Best. Cache validates both size and hash — skips download with no network request. |
| Set | — | Cache 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
pathfield 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
- Does the file exist at the expected
save_path? - Does the file size on disk match
expected_size? - (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
| Context | When |
|---|---|
DownloadProgress with auto_start = false | During check() — runs in a background thread for all groups simultaneously. |
DownloadProgress with auto_start = true | Per-task, inside each worker thread before connecting. |
| DLClient | Per-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:
| Type | Example | Resolution |
|---|---|---|
| Local file | res://manifests/core.json, user://cache/manifest.json | Read synchronously on the main thread. |
| Remote URL | https://cdn.example.com/manifest.json | Fetched 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: Resolve → Enqueue → Download
│
├── 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.
| Constant | Location | Value | Description |
|---|---|---|---|
CONNECT_TIMEOUT_MS | HttpUtil | 10 000 ms | Maximum time to establish a TCP/TLS connection. |
REQUEST_TIMEOUT_MS | HttpUtil | 10 000 ms | Maximum time waiting for the server to begin responding after the request is sent. |
DATA_TIMEOUT_MS | HttpUtil | 10 000 ms | Maximum time without receiving any data during body transfer. |
MAX_RETRIES | DownloadWorker | 3 | Number of download attempts before giving up. |
MAX_REDIRECTS | DownloadWorker | 5 | Maximum consecutive HTTP redirects before failing. |
RETRY_BASE_DELAY_MS | DownloadWorker | 1 000 ms | Base delay between retries. Multiplied by attempt number (1 s, 2 s, 3 s). |
PROGRESS_INTERVAL_MS | DownloadWorker | 100 ms | Minimum 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
| Class | Extends | Visibility | Description |
|---|---|---|---|
DownloadProgress | Node | Public | Orchestrates sequential group downloads. Add to your loading scene. |
DownloadGroup | Node | Public | Defines a batch of files from a JSON manifest. Child of DownloadProgress. |
DownloadClient | Node | Public (autoload as DLClient) | Singleton for on-demand runtime downloads. |
HttpUtil | RefCounted | Internal | Shared URL parsing, JSON parsing, and blocking HTTP fetch. |
DownloadTask | RefCounted | Internal | Thread-safe data for one file (url, progress, status). |
DownloadWorker | RefCounted | Internal | Static HTTP download engine. Runs in worker threads. |
DownloadThreadPool | RefCounted | Internal | Manages worker threads with a semaphore-based task queue. |
DownloadTask.Status Enum
| Value | Description |
|---|---|
PENDING | Task created, not yet started. |
DOWNLOADING | Currently being downloaded by a worker thread. |
DONE | Completed successfully (or cache hit). |
ERROR | Failed 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.
| Error | When |
|---|---|
| Connection timeout | TCP/TLS handshake exceeds 10 s. |
| Request timeout | Server does not respond within 10 s after the request is sent. |
| Data timeout | No data received for 10 s during body transfer. |
| HTTP error | Server returns a non-200, non-redirect status code. |
| Too many redirects | More than 5 consecutive redirects. |
| Hash mismatch | Post-download SHA-256 does not match expected_hash (not retried). |
| File error | Cannot 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
| Limitation | Detail |
|---|---|
| No resume | If a download is interrupted, the file is re-downloaded from the beginning. |
| No cancel / pause | There is no API to cancel or pause an in-progress download. |
| JSON manifests only | Manifest files must be JSON. CSV, XML, or other formats are not supported. |
| No per-file signals on DLClient | The DLClient singleton emits aggregate progress per download ID, not per individual file. |