GDScript SDK Overview

The GDScript SDK is the runtime library through which Godot games written in GDScript load and access config data exported by Archmage.
Archmage is a configuration solution for game development. It brings together the following: specifications on how to structure config data, define fields, and fill in values; pipelines that export runtime data and generate strongly-typed code; multi-language SDKs for loading and accessing that data at runtime; and a collaborative editing workflow for teams.
The SDK is built around the concept of an Atlas — a registry that maps named keys to configurations. Each key is associated with one or more JSON files. At runtime, the SDK reads these files, converts them into instances of generated GDScript classes, resolves cross-table references, and calls post-load hooks.
Key features
- I18n — multi-language text management with automatic fallback
- Refs — a ref field holds the config entry it references, resolved at load time
- Duration — nanosecond precision; converts to seconds for Godot APIs
- MinMax — random value selection within a range
- WeightedPool — weighted random selection with probability proportional to item weight
- Variants — switch an atlas item to use an alternative data set at load time via
with_variant - Whitelist/Blacklist — load only a subset of atlas items
- Layered overrides — merge files with matching relative paths from additional override sources (a directory path or a custom file system) into the base configs, field by field, at load time
- Synchronous and asynchronous loading —
await load_atlas_async()loads atlas items in parallel on theWorkerThreadPool, and also works in Web exports without thread support; theprogresssignal andcancel()serve a loading screen - Pluggable file system — load from
res://,user://, memory, or any other source viaArchmage.FS - Versioning — VCS metadata (branch, commit, timestamp, etc.), when present in
atlas.json, is available on the loaded atlas - Editor plugin — Inspector dropdowns for config ID properties, populated from the loaded atlas, for easy selection
Requirements
Section titled “Requirements”- Godot 4.6 or later. The SDK is pure GDScript, so the .NET edition of Godot is not needed.
Installation
Section titled “Installation”Download the zip file of the latest release from GitHub Releases and extract it into your project, so that the SDK is in addons/archmage/. Alternatively, copy addons/archmage/ from the repository.
The SDK is not an editor plugin, so there is nothing to enable in Project Settings. To upgrade, delete addons/archmage/ and install the new version.
Generating Code
Section titled “Generating Code”Generate the enum code with the gd template and the config code with the json-gd template:
archmage enum -t gd -o enums "configs/*.enum.yaml"archmage struct -t json-gd -o conf -e "configs/*.enum.yaml" "configs/*.xlsx"See archmage struct for the other flags.
To show config ID properties as dropdowns in the Inspector, also generate the editor plugin.
Getting Started
Section titled “Getting Started”# ConfigAtlas is generated by Archmagevar atlas := ConfigAtlas.new()var loader := Archmage.AtlasLoader.new()var err := loader.load_atlas("res://configs/atlas.json", "res://configs", atlas)if err != OK: push_error(loader.get_error_message()) return
var hero := atlas.hero_table.lookup(1)print(hero.level, hero.weapon.id)As with JSON.parse(), load_atlas returns an Error code, OK on success. get_error_message() of the loader returns the message, which locates the failure, such as <archmage> failed to bind refs | hero[3].weapon: ItemTable: config entry not found for ID 999.
Concepts
Section titled “Concepts”atlas.json is generated by Archmage. It declares how each config key maps to its JSON files using one of three strategies:
| Strategy | Shape | Behavior |
|---|---|---|
| unique | key → "file.json" |
Loads one file into the config object |
| variant | key → { "/": "file.json", "alt": "file-alt.json" } |
Selects one variant by case; "/" is the default |
| many | key → ["a.json", "b.json"] |
Loads and merges multiple files in order |
Example atlas.json:
{ "unique": { "hero": "hero.json", "item": "clutter/item.json" }, "variant": { "game": { "/": "game.json", "hard": "game_hard.json" } }, "many": { "weapon": [ "vtbl/weapon-sword.json", "vtbl/weapon-staff.json" ] }}Generated Code
Section titled “Generated Code”ConfigAtlas has one field per config source, such as atlas.hero_table and atlas.game_cfg. Each config source has its own file and class, such as HeroTable; the other types of the source are inner classes, such as HeroTable.HeroCfg. Field names are snake_case: critRate becomes crit_rate. A field whose name is a GDScript keyword gets a trailing underscore: class becomes class_.
A table, such as HeroTable, holds its config entries in entries, a Dictionary keyed by config ID, and provides these methods:
| Method | Returns |
|---|---|
lookup(id) |
The config entry; null after reporting an error with push_error when there is none |
try_lookup(id) |
The config entry, or null when there is none |
has(id) |
Whether there is a config entry for id |
size() |
The number of config entries |
lookup of the zero ID (0 or "") returns null without an error. A for loop iterates over the config entries:
for hero in atlas.hero_table: print(hero.id, " ", hero.level)A config ID is a plain int or String. A ref field holds the config entry it references: hero.weapon is an ItemTable.ItemCfg, and its ID is hero.weapon.id. A ref field is null when the ID is the zero value; a non-zero ID without a config entry makes the load fail.
AtlasExtension (atlas_extension.gd) is generated once and never regenerated. Its on_loaded(atlas) runs after all configs are loaded and all refs are resolved. It returns "", or an error message that makes the load fail.
Loading Configs
Section titled “Loading Configs”Loading proceeds in the following steps:
- Parse
atlas.json - Apply the atlas modifier (if set)
- For each atlas item: read files → merge overrides → convert
- Resolve cross-table references
AtlasExtension.on_loaded()— post-load initialization
load_atlas does all work on the calling thread. load_atlas_async does the same work without blocking the main thread; call it on the main thread with await:
var err := await loader.load_atlas_async("res://configs/atlas.json", "res://configs", atlas, opts)Threads of the WorkerThreadPool read, parse and convert the atlas items; the atlas modifier, the logger and on_loaded run on the main thread. Without thread support, as in a Web export without threads, the atlas items are loaded on the main thread, one per frame. Do not use the atlas until the load finishes, because its objects are filled over several frames.
A loader runs one load at a time, and an atlas is loaded by one loader at a time; otherwise the load returns ERR_BUSY. Create a new atlas for each load. To keep using the loaded configs during a reload, load into a new atlas and replace the old one when the load succeeds:
func reload_configs() -> void: var new_atlas := ConfigAtlas.new() var loader := Archmage.AtlasLoader.new() var err := await loader.load_atlas_async("res://configs/atlas.json", "res://configs", new_atlas) if err != OK: push_error(loader.get_error_message()) return atlas = new_atlasThe progress(loaded: int, total: int, key: String) signal of the loader reports the atlas items loaded so far, for example for a loading screen. It is emitted once with loaded 0 when the atlas items to load are known, and once after each atlas item is loaded. total does not include the atlas items that the whitelist or the blacklist skips. load_atlas_async emits it on the main thread.
loader.progress.connect(_on_load_progress)
func _on_load_progress(loaded: int, total: int, key: String) -> void: progress_bar.max_value = total progress_bar.value = loadedcancel() stops a running load before its next atlas item. load_atlas_async still waits for the atlas items that worker threads are loading. A canceled load returns ERR_SKIP without resolving the references or calling on_loaded, and the atlas is partly filled, so do not use it. During load_atlas, call cancel() from a progress handler.
func _on_cancel_pressed() -> void: loader.cancel() # the pending load_atlas_async returns ERR_SKIPWhen an atlas is freed, it sets the ref fields of all its config entries to null, so that config entries that reference each other can be freed. A config entry that your code still holds stays valid, but its ref fields are null. Keep the atlas, for example in an autoload, for as long as you use its config entries, and look up the config entries again after a reload.
Error Codes
Section titled “Error Codes”| Code | Cause |
|---|---|
ERR_INVALID_PARAMETER |
An invalid option, such as an unknown key in the whitelist, an empty variant, or an override root that is not a directory |
The code of the file system, such as ERR_FILE_NOT_FOUND |
A file cannot be read |
ERR_PARSE_ERROR |
A file is not valid JSON, atlas.json does not have its expected shape, a value has the wrong JSON type, such as a string for an int field, or a value is invalid, such as a duration |
ERR_DOES_NOT_EXIST |
atlas.json has no file for an atlas item that is loaded |
ERR_INVALID_DATA |
A non-zero ID has no config entry, or an atlas item has an unknown mapping |
FAILED |
AtlasExtension.on_loaded() returned an error message |
ERR_BUSY |
Another loader is loading the same atlas, or this loader has not finished an earlier load |
ERR_SKIP |
cancel() was called during the load |
Options
Section titled “Options”Configure loading with an Archmage.AtlasOptions, passed as the last argument of load_atlas or load_atlas_async. Each with_* method returns the options, so the calls can also be chained.
var opts := Archmage.AtlasOptions.new()# custom logger (default: print; Archmage.NullLogger discards the messages)opts.with_logger(Archmage.NullLogger.new())# replace the default file system (Archmage.FileAccessFS)opts.with_fs(my_fs)# load only these keysopts.with_whitelist(["hero", "item"])# skip these keysopts.with_blacklist(["debug"])# select a variantopts.with_variant("game", "hard")# add an override directoryopts.with_override_root("user://override")# add an override file systemopts.with_override_fs(patch_fs)# change atlas.json after parsingopts.with_atlas_modifier(func(aj: Archmage.AtlasJson) -> void: aj.unique.erase("debug"))# load_atlas_async loads at most 8 atlas items at the same time (default: 32)opts.with_max_concurrency(8)A custom logger extends Archmage.AtlasLogger and overrides info(message).
Whitelist and blacklist — If a non-empty whitelist is set, only listed keys are loaded (blacklist is ignored). Every key in either list must be an atlas item, or the load fails.
Variant selection — A variant-mapped key loads its "/" variant unless with_variant
selects another one. The variant in use is recorded in the variant field of the atlas item, such as atlas.atlas_items()["game"].variant.
Override layers — Each with_override_root / with_override_fs call adds another
override source. When loading an atlas item, each override source is checked in the order in which it
was added; any matching file is merged on top of the base data, so a later source overrides an earlier one.
Field-level merge rules during override processing:
| Value in override | Behavior |
|---|---|
null |
Resets the target field to its default value |
| JSON object | Recursively merges — only fields present in the override are updated, others remain unchanged |
| Any other value, including an array | Overwrites the field |
Custom File System
Section titled “Custom File System”with_fs and with_override_fs accept an Archmage.FS. The default, Archmage.FileAccessFS, reads res:// paths, user:// paths and operating system paths with FileAccess, including files in mounted resource packs; mount the packs before loading starts. To read files from another source, such as memory, extend Archmage.FS:
extends Archmage.FS
var files: Dictionary[String, PackedByteArray] = {}
func read_file(path: String) -> Variant: if not files.has(path): return ERR_FILE_NOT_FOUND return files[path]
func file_exists(path: String) -> bool: return files.has(path)Notes for Archmage.FS implementations:
read_filereturns the content as aPackedByteArray, or anErrorcode. It must returnERR_FILE_NOT_FOUNDwhen the file does not exist: a missing override file is skipped only with this code.file_existsanddir_existsreturntrueunless overridden. Keep that default when an exact check is expensive.load_atlas_asynccallsread_fileandfile_existson worker threads, several at a time, so they must be safe to call that way. Protect any state that the calls share, such as oneZIPReaderused for all reads, with aMutex.dir_existsruns on the thread that starts the load.
Special Types
Section titled “Special Types”Duration
Section titled “Duration”Archmage.Duration holds a length of time in nanoseconds, an int. to_seconds() returns a float for Godot APIs that take seconds, and to_milliseconds() returns whole milliseconds.
await get_tree().create_timer(skill.cooldown.to_seconds()).timeoutTo get seconds as a float instead, set ARCHMAGE_GD_FLOAT_DURATION to true when you generate the code. Each duration is then a float that holds seconds, and the to_json() of a config returns it as a number of seconds, which load_json() cannot read back.
DateTime
Section titled “DateTime”Archmage.DateTime holds a point in time: unix_time in seconds, nanosecond within the second, and offset, the UTC offset in seconds that the config file was written with. is_zero() returns whether it is the zero value, 0001-01-01T00:00:00Z.
An rgba field is a Godot Color.
Vectors
Section titled “Vectors”A vector field whose elements are float32 is a Vector2, Vector3 or Vector4, and one whose elements are int32 is a Vector2i, Vector3i or Vector4i. A vector field of any other element type is an Archmage.Vec2, Vec3 or Vec4, whose x, y, z and w hold 64-bit values; to_godot() converts it to a Godot vector. The --float-bit-size 32 and --int-bit-size 32 flags of archmage struct make float and int 32-bit.
MinMax
Section titled “MinMax”Archmage.MinMax is a range bounded by min and max, both included. sample() draws a random value from the range. The bounds are int, float or Archmage.Duration values; an Archmage.Duration is drawn in whole milliseconds. With ARCHMAGE_GD_FLOAT_DURATION, a duration range has float bounds and is sampled like any other float range.
WeightedPool
Section titled “WeightedPool”Archmage.WeightedPool holds parallel items and weights arrays. sample() and sample_index() draw an item (or its index) at random with probability proportional to its weight; sample_with(value) maps a value from 0 to 1 to an item, so that the same value always maps to the same item. When the pool is empty or its total weight is not from 1 to 1,000,000,000, they report an error and return null or -1.
duplicate() returns a shallow copy of the pool with its own items and weights arrays, for code that changes the weights at run time without changing the config entry. The items themselves, such as config entries, are shared.
MinMax.sample(), WeightedPool.sample() and WeightedPool.sample_index() take an optional RandomNumberGenerator; without it, they use the global random number generator.
Archmage.Tuple holds 1 to 7 values of different types in items, also available as item0 to item6.
Localization
Section titled “Localization”Archmage.I18n holds per-language translations and falls back to a default language when a key is missing. A language is a plain string, such as "fr". archmage export writes the texts of the source language to l10n.json; the translations are files of the same format, such as l10n.fr.json.
var i18n := Archmage.I18n.new("en")var err := i18n.merge_l10n_file("res://configs/l10n.json", "en")if err == OK: err = i18n.merge_l10n_file("res://configs/l10n.fr.json", "fr")if err != OK: push_error(i18n.get_error_message())
i18n.text("ui.ok", "fr") # → "D'accord"i18n.text("ui.ok", "ja") # → falls back to "OK" in "en"i18n.text("ui.xx", "ja") # → no translation found, so returns "ui.xx"To detect a missing translation, use get_text, which returns null when neither the language nor the fallback language has the key. merge_l10n_file_async reads and parses the file on a worker thread; call it with await.
L10n Fields
Section titled “L10n Fields”An l10n field, such as atlas.game_cfg.title, is an L10n: a generated class that holds the l10n key and looks up its text in a shared Archmage.I18n. Set L10n.get_i18n and L10n.get_preferred_language before use, for example in an autoload:
extends Node
var i18n := Archmage.I18n.new("en")
func _ready() -> void: L10n.get_i18n = get_i18n L10n.get_preferred_language = get_preferred_language
func get_i18n() -> Archmage.I18n: return i18n
func get_preferred_language() -> String: return "fr"Set them to methods, as above, or to static functions. If a lambda is still set when the game exits, Godot 4.6.3 aborts; to use a lambda, set the variable back to Callable() before exit, such as in _exit_tree().
Then, in your code:
var title := atlas.game_cfg.title.texttext returns the key when no language has it; get_text(lang) returns null then. The == operator compares two L10n objects, not their keys; to compare keys, compare their key properties.
To use less memory, set ARCHMAGE_GD_STRING_L10N to true when you generate the code: each l10n field is then a String that holds the l10n key, and L10n.text(key) and L10n.get_text(key, lang) look up its text.
Godot Translations
Section titled “Godot Translations”The SDK does not register the texts with the TranslationServer. To translate them with tr() or the automatic translation of Control nodes, build a Translation for each language from get_all_texts():
var all_texts := i18n.get_all_texts()for lang: String in all_texts: var translation := Translation.new() translation.locale = lang var texts: Dictionary = all_texts[lang] for key: String in texts: translation.add_message(key, texts[key]) TranslationServer.add_translation(translation)tr() does not fall back to the fallback language of the I18n, but to the locale of the project setting internationalization/locale/fallback, and it treats an empty text as missing. Use language strings that Godot recognizes as locales, such as fr or zh_CN.
Data Versioning
Section titled “Data Versioning”atlas.json can carry a version block with VCS metadata (branch, commit ID, timestamp, author). After loading, it is available on the atlas:
{ "version": { "branch": "main", "id": "a1b2c3d4e5f6...", "shortId": "a1b2c3d", "timestamp": "2025-01-01T00:00:00Z" }, ...}var ver := atlas.data_version # Archmage.VersionInfo, null if not presentif ver != null: print(ver.branch) # "main" print(ver.short_id) # "a1b2c3d" print(ver.timestamp.to_json()) # "2025-01-01T00:00:00Z"; timestamp is an Archmage.DateTimeConfigAtlas.code_version() returns the version info that the code was generated from, or null. Comparing the two reveals any version difference between code and data.
Limits
Section titled “Limits”Godot parses every JSON number as a 64-bit float. An integer whose absolute value is above 2^53 (9007199254740992) loses precision, and a uint64 value above the int64 range cannot be represented. A number in an integer field is converted with int(); the load does not check that it is a whole number or within the range of the field type.
See Also
Section titled “See Also”- Documentation: https://docs.shadop.dev/archmage/overview-gd/sdk-gd/
- API Reference: https://docs.shadop.dev/archmage/sdk-gd/archmage/
- Generated Code: https://docs.shadop.dev/archmage/gen-gd/configatlas/
- Editor Plugin: https://docs.shadop.dev/archmage/gen-gd-editor/godot-editor/
- Source Code: https://github.com/shadowopera/sdk-gd