Skip to content

GDScript SDK Overview

Archmage

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 the WorkerThreadPool, and also works in Web exports without thread support; the progress signal and cancel() serve a loading screen
  • Pluggable file system — load from res://, user://, memory, or any other source via Archmage.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
  • Godot 4.6 or later. The SDK is pure GDScript, so the .NET edition of Godot is not needed.

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.

Generate the enum code with the gd template and the config code with the json-gd template:

Terminal window
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.

# ConfigAtlas is generated by Archmage
var 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.

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" ]
}
}

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 proceeds in the following steps:

  1. Parse atlas.json
  2. Apply the atlas modifier (if set)
  3. For each atlas item: read files → merge overrides → convert
  4. Resolve cross-table references
  5. 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_atlas

The 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 = loaded

cancel() 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_SKIP

When 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.

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

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 keys
opts.with_whitelist(["hero", "item"])
# skip these keys
opts.with_blacklist(["debug"])
# select a variant
opts.with_variant("game", "hard")
# add an override directory
opts.with_override_root("user://override")
# add an override file system
opts.with_override_fs(patch_fs)
# change atlas.json after parsing
opts.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

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:

memory_fs.gd
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_file returns the content as a PackedByteArray, or an Error code. It must return ERR_FILE_NOT_FOUND when the file does not exist: a missing override file is skipped only with this code.
  • file_exists and dir_exists return true unless overridden. Keep that default when an exact check is expensive.
  • load_atlas_async calls read_file and file_exists on worker threads, several at a time, so they must be safe to call that way. Protect any state that the calls share, such as one ZIPReader used for all reads, with a Mutex. dir_exists runs on the thread that starts the load.

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()).timeout

To 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.

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.

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.

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.

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.

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.

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.text

text 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.

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.

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 present
if 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.DateTime

ConfigAtlas.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.

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.