Getting data in and out of a Godot 4.x game and shipping it: res:// and user://, loading and threaded
loading, saving Resources, ConfigFile, FileAccess, JSON and DirAccess, project settings and feature tags,
export presets and the command line, the Web export, debugging tools, C# and version control. Custom Resource
classes are introduced in Nodes, scenes & signals.
Project Settings > Application > Config > Use Custom User Dir (drops the Godot/app_userdata part)
Web
IndexedDB in the browser (persists per origin)
Android / iOS
the app's private storage
print(OS.get_user_data_dir()) # absoluteprint(ProjectSettings.globalize_path("user://saves"))OS.shell_open(ProjectSettings.globalize_path("user://"))var p := "user://saves".path_join("slot_1.json")
Paths use / on every platform. res:// paths are case-sensitive once exported (the .pck is), even if your
Windows or macOS file system is not: a wrong-case load() that works in the editor fails in the export.
Loading resources
const PLAYER := preload("res://actors/player.tscn")func _ready() -> void: var tex: Texture2D = load("res://art/icon.png") # cached var fresh := ResourceLoader.load("user://save.tres", "", ResourceLoader.CACHE_MODE_IGNORE) if ResourceLoader.exists("res://levels/level_9.tscn"): pass
API
Notes
preload(path)
constant path, loaded when the script loads; dependency visible to the exporter
load(path)
runtime; returns the cached instance if already loaded
ResourceLoader.load(path, type_hint, cache_mode)
control caching: CACHE_MODE_IGNORE, REUSE (default), REPLACE, IGNORE_DEEP, REPLACE_DEEP
ResourceLoader.exists(path)
check without loading
ResourceLoader.list_directory(dir)
lists resources as the loader sees them (works in exports where files are remapped)
resource.duplicate(deep)
independent copy of a shared resource
resource.resource_path
where it came from ("" for built-in resources)
What is actually in an export: imported assets (.png, .wav, .glb ...) are shipped in their converted
form from .godot/imported/, not the source file. So load("res://icon.png") works, but
FileAccess.open("res://icon.png") and Image.load_from_file on res:// fail. Plain data files the engine
does not know (.txt, .csv set to not import, custom formats) are only exported if listed in the preset's
"Filters to export non-resource files/folders" (for example *.txt, data/*).
Threaded loading
const LEVEL := "res://levels/level_2.tscn"func start() -> void: ResourceLoader.load_threaded_request(LEVEL)func _process(_d: float) -> void: var progress := [] var status := ResourceLoader.load_threaded_get_status( LEVEL, progress) match status: ResourceLoader.THREAD_LOAD_IN_PROGRESS: $Bar.value = progress[0] * 100.0 # 0..1 ResourceLoader.THREAD_LOAD_LOADED: var scene: PackedScene = \ ResourceLoader.load_threaded_get(LEVEL) get_tree().change_scene_to_packed(scene) set_process(false) ResourceLoader.THREAD_LOAD_FAILED, \ ResourceLoader.THREAD_LOAD_INVALID_RESOURCE: push_error("could not load " + LEVEL) set_process(false)
On the Web without thread support, "threaded" loading still works but runs on the main thread, so the progress
bar updates in steps.
Saving Resources
save_data.gd
class_name SaveDataextends Resource@export var level := "res://levels/level_1.tscn"@export var position := Vector2.ZERO@export var inventory: Dictionary[StringName, int] = {}@export var flags: PackedStringArray = []
const PATH := "user://save.tres" # .tres text, .res binaryfunc save_game(data: SaveData) -> void: var err := ResourceSaver.save(data, PATH) if err != OK: push_error(error_string(err))func load_game() -> SaveData: if not ResourceLoader.exists(PATH): return SaveData.new() return ResourceLoader.load(PATH, "", ResourceLoader.CACHE_MODE_IGNORE) as SaveData
Fact
Detail
Format
extension picks it: .tres (text, diffable) or .res (binary, smaller)
# Textvar f := FileAccess.open("user://log.txt", FileAccess.WRITE)if f == null: push_error(error_string(FileAccess.get_open_error())) returnf.store_line("started")f.store_string("no newline")f.close() # or let it go out of scopevar text := FileAccess.get_file_as_string("user://log.txt")var r := FileAccess.open("user://log.txt", FileAccess.READ)while not r.eof_reached(): print(r.get_line())# Binary Variants (Godot's own serialization)const BIN := "user://state.bin"var w := FileAccess.open(BIN, FileAccess.WRITE)w.store_var({"hp": 3, "pos": Vector2(4, 5)})w.close()var rd := FileAccess.open(BIN, FileAccess.READ)var state: Dictionary = rd.get_var() # objects refused
AES-encrypted file (deters casual editing; the key ships with the game)
open_compressed(path, mode, compression)
compressed file
flush(), close()
writes are flushed on close or when the object is freed
JSON
var data := {"name": "Ada", "level": 3, "tags": ["a", "b"]}var text := JSON.stringify(data, "\t") # prettyvar parsed = JSON.parse_string(text) # null on errorif parsed is Dictionary: var level := int(parsed["level"]) # numbers: floatvar json := JSON.new() # with errorsif json.parse(text) != OK: push_error("line %d: %s" % [ json.get_error_line(), json.get_error_message()])else: var d = json.data
Fact
Detail
Numbers
every JSON number parses as float: convert ids and counts with int()
Godot types
Vector2, Color ... are not JSON: store [x, y], or use JSON.from_native() / JSON.to_native()
Objects
from_native / to_native skip Objects unless you opt in: keep it off for untrusted input
Key order
stringify(data, indent, sort_keys := true); pass false to keep insertion order
Precision
stringify(..., full_precision := true) for exact floats
Alternative
var_to_str() / str_to_var() use Godot's own text format and keep all types (trusted data only)
DirAccess
DirAccess.make_dir_recursive_absolute("user://saves/slots")print(DirAccess.dir_exists_absolute("user://saves"))for file in DirAccess.get_files_at("user://saves"): if file.ends_with(".json"): print(file)for dir in DirAccess.get_directories_at("user://"): print(dir)DirAccess.rename_absolute("user://a.json", "user://b.json")DirAccess.remove_absolute("user://b.json") # file or # empty dirvar d := DirAccess.open("user://saves")if d: print(d.get_space_left())
In exported games, listing res:// returns .import / .remap files rather than the originals; use
ResourceLoader.list_directory() or keep an explicit list of resources.
var title: String = ProjectSettings.get_setting( "application/config/name")if OS.has_feature("web"): $QuitButton.hide() # no quitting a browser tabif OS.has_feature("mobile"): $TouchControls.show()if OS.is_debug_build(): $DebugOverlay.show()
Feature tag
True when
editor / template
running in the editor / an exported build
debug / release
debug or release template (the editor counts as debug)
windows, macos, linuxbsd, android, ios, web
platform
pc, mobile
platform family
web_android, web_ios
Web export running on a mobile browser
movie
Movie Maker mode
custom tags
added in an export preset (for example demo, steam)
Settings can be overridden per feature tag in the Project Settings UI (the "Add override" option), which stores
keys like window/size/viewport_width.mobile. An override.cfg next to the executable overrides settings in a
shipped game.
Export presets & templates
Step
Detail
1. Templates
Editor > Manage Export Templates: download templates that exactly match your editor version (4.7+ can fetch single platforms)
2. Preset
Project > Export > Add...: one preset per platform or variant; saved in export_presets.cfg
3. Resources tab
export all, selected scenes, selected resources, or all except; include/exclude filters
Required response headers for a threaded (Thread Support) build: Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corpMIME types: .wasm application/wasm (enables streaming compilation) .pck application/octet-streamCompression: pre-compress .wasm and .pck with brotli/gzip andsend Content-Encoding (gzip alone brings .wasm to about a quarter)
Host
Notes
itch.io
upload a zip with index.html; tick SharedArrayBuffer support only for threaded builds
GitHub Pages, static hosts without headers
use a single-threaded export, or the PWA option's service worker that injects the isolation headers
Your own server
set the headers above (see Recipes)
Size tips: strip unused modules with a custom template and build profile, keep the .pck lean (compressed
textures, Ogg instead of WAV for long audio), and serve compressed assets. WebAssembly background:
WebAssembly fundamentals.
Desktop & mobile notes
Platform
Notes
Windows
.exe + .pck (or embedded); sign with signtool or osslsigncode to avoid SmartScreen warnings; D3D12 is the default driver since 4.6, Vulkan and OpenGL available
macOS
universal .app or .dmg; code signing plus notarisation (Apple Developer ID) for Gatekeeper; export from any OS, notarise on macOS or via rcodesign
Linux
x86_64 / arm64 binaries + .pck; ship as tarball, AppImage or Flatpak
Android
needs Android SDK and a JDK configured in Editor Settings; debug keystore generated automatically; Play Store needs an AAB (Gradle build)
iOS
exports an Xcode project; build and sign on macOS with Xcode
Steam
embed PCK or ship both files; GodotSteam (GDExtension) for the Steamworks API
Debugging tools
Tool
Where
Remote scene tree
Scene dock > Remote while the game runs; inspect and edit live node properties
Embedded game view
the Game tab (4.4+): run inside the editor, select nodes by clicking in the game
Debugger > Stack Trace, Errors
breakpoints (F9 or breakpoint), step in/over/out, variables; errors and warnings with stack
Profiler
per-function script time and frame time; start it, then play
Visual Profiler
CPU and GPU time per rendering stage
Monitors
FPS, memory, object and orphan-node counts, draw calls, physics stats
Network Profiler
RPC and sync bandwidth
Video RAM
textures and meshes by size
ObjectDB snapshots
capture and diff live objects to hunt leaks (4.6+)
External profilers (Tracy, Perfetto, Instruments) are supported by engine builds with profiling enabled (4.6+),
and release builds can print script backtraces for errors (4.5+).
C# (.NET) projects
Fact
Detail
Editor
the separate ".NET" download of Godot; needs the .NET SDK (8 or newer since 4.4)
Build
the editor runs dotnet build; a .csproj and .sln sit next to project.godot
Platforms
Windows, macOS, Linux, Android and iOS; no Web export in Godot 4.x
Mixing
C# and GDScript can coexist and call each other through Call(), signals and properties
Exports
use the .NET export templates (also a separate download)
When
large codebases, existing .NET libraries, stronger tooling; GDScript for fastest iteration and Web
per-asset import settings (the imported output in .godot/ is regenerated)
*.uid (4.4+)
stable ids for scripts and shaders; keep them next to their source when moving files
.gitattributes
* text=auto eol=lf; Godot generates it with the project
Use Git LFS for large binary assets (.png, .wav, .ogg, .glb, .blend). An empty .gdignore file in a
folder hides it from the editor (raw art sources, docs, node_modules).
Recipes
JSON save with atomic write
Use for save slots: write to a temp file, then rename, so a crash mid-write never corrupts the old save.
save_system.gd (autoload)
extends Nodeconst DIR := "user://saves"const VERSION := 2func save_slot(slot: int, data: Dictionary) -> Error: DirAccess.make_dir_recursive_absolute(DIR) var path := DIR.path_join("slot_%d.json" % slot) var tmp := path + ".tmp" data["version"] = VERSION var f := FileAccess.open(tmp, FileAccess.WRITE) if f == null: return FileAccess.get_open_error() f.store_string(JSON.stringify(data, "\t")) f.close() return DirAccess.rename_absolute(tmp, path)func load_slot(slot: int) -> Dictionary: var path := DIR.path_join("slot_%d.json" % slot) if not FileAccess.file_exists(path): return {} var d = JSON.parse_string( FileAccess.get_file_as_string(path)) if d is not Dictionary: push_warning("corrupt save " + path) return {} return _migrate(d)func _migrate(d: Dictionary) -> Dictionary: if int(d.get("version", 1)) < 2: d["gold"] = d.get("coins", 0) # renamed field return d
Settings with ConfigFile
Use for an options menu: apply at startup, save on change.
settings.gd (autoload)
extends Nodeconst PATH := "user://settings.cfg"var cfg := ConfigFile.new()func _ready() -> void: cfg.load(PATH) # missing file is fine apply()func setv(section: String, key: String, v: Variant) -> void: cfg.set_value(section, key, v) cfg.save(PATH) apply()func apply() -> void: var full: bool = cfg.get_value("video", "fullscreen", false) DisplayServer.window_set_mode( DisplayServer.WINDOW_MODE_FULLSCREEN if full else DisplayServer.WINDOW_MODE_WINDOWED) var music: float = cfg.get_value("audio", "music", 0.8) var i := AudioServer.get_bus_index("Music") AudioServer.set_bus_volume_db(i, linear_to_db(music))
Loading screen
Use between large levels: load in the background, show progress, then swap scenes.
loading_screen.gd
extends Controlvar target := ""func load_level(path: String) -> void: target = path ResourceLoader.load_threaded_request(path, "", true) set_process(true)func _process(_d: float) -> void: var p := [] var s := ResourceLoader.load_threaded_get_status( target, p) if s == ResourceLoader.THREAD_LOAD_IN_PROGRESS: %Bar.value = p[0] * 100.0 elif s == ResourceLoader.THREAD_LOAD_LOADED: set_process(false) var scene: PackedScene = \ ResourceLoader.load_threaded_get(target) get_tree().change_scene_to_packed(scene) else: set_process(false) push_error("failed to load " + target)
Serve a threaded Web build
Use to test or host a Thread Support export locally with the required isolation headers (Bun).
serve.ts
const root = "./build/web";const headers = { "Cross-Origin-Opener-Policy": "same-origin", "Cross-Origin-Embedder-Policy": "require-corp",};Bun.serve({ port: 8060, async fetch(req) { let path = new URL(req.url).pathname; if (path === "/") path = "/index.html"; const file = Bun.file(root + path); if (!(await file.exists())) { return new Response("Not found", { status: 404 }); } return new Response(file, { headers }); // MIME inferred },});
CI export on GitHub Actions
Use to build a Web export on every push; pin the exact Godot version your project uses.