DataRegistry and the Data Files

DataRegistry loads every data/*.json file into memory at startup and exposes typed lookup helpers. It's the single read path for tuning data — materials, tools, recipes, machines, layers, discoveries, balance, trades, buildings, codes, and cosmetics — so nothing else in the codebase opens a JSON file directly.

What DataRegistry loads

scripts/resources/DataRegistry.gd (extends Node) reads each file in _ready() via _load_all_data(). The results are held in plain dictionaries and arrays:

Field Source file Shape
materials data/materials.json id -> Dictionary
layers data/layers.json Array (the file's layers array)
recipes data/recipes.json id -> Dictionary
tools data/tools.json id -> Dictionary
machine_defs data/machines.json type -> Dictionary
discoveries data/discoveries.json id -> Dictionary
balance data/balance.json section -> Dictionary
trades data/trades.json villager visit economy
buildings data/buildings.json bell building defs
codes data/codes.json SHA-256 hash -> Dictionary
cosmetics data/cosmetics.json items, dyes, slot_order

layers.json is the one file whose top level is an object wrapping an array — DataRegistry unwraps it: layers = layer_data.get("layers", []). The _comment meta key is stripped from buildings, codes, and cosmetics after load so consumers never iterate a fake entry.

Loading is defensive. _load_json() returns {} (not a crash) if a file is missing, cannot be opened, or fails to parse, pushing a warning or error instead:

func _load_json(path: String) -> Dictionary:
    if not FileAccess.file_exists(path):
        push_warning("[DataRegistry] File not found: %s" % path)
        return {}
    ...
    if err != OK:
        push_error("[DataRegistry] Parse error in %s: %s" % [path, json.get_error_message()])
        return {}
    return json.data

The lookup API

Every getter returns a Dictionary (or the collection type), and every by-id getter returns {} on a miss rather than null or an error:

DataRegistry.get_material(material_id)   # -> Dictionary  ({} on miss)
DataRegistry.get_tool(tool_id)           # -> Dictionary  ({} on miss)
DataRegistry.get_recipe(recipe_id)       # -> Dictionary  ({} on miss)
DataRegistry.get_machine_def(type)       # -> Dictionary  ({} on miss)
DataRegistry.get_discovery_def(id)       # -> Dictionary  ({} on miss)

Additional helpers layer computed or nested lookups on top:

Method Returns Notes
get_code(code_id) Dictionary Hashes the trimmed, uppercased input to SHA-256 before lookup — plaintext codes never appear in codes.json.
get_craft_duration(recipe_id) float craft_time (default 5.0) divided by balance.crafting.speed_multiplier.
get_layer_at_depth(depth) Dictionary Scans layers for the band whose depth_start/depth_end contains depth.
get_layer_id_at_depth(depth) String id of that layer, "" if none.
get_cosmetic(item_id) Dictionary Reads from cosmetics.items.
get_cosmetic_dye(dye_id) Dictionary Reads from cosmetics.dyes.
get_cosmetic_slot_order() Array The slot_order array.
get_cosmetics_for_slot(slot) Array Items matching slot, each stamped with its id.
get_default_cosmetic_for_slot(slot) String The id of the slot's default: true item.

The "empty on miss" contract

Because getters return {} on a miss, callers must always read fields through .get("key", default) — indexing a missing key on an empty dictionary would error. This is the standard pattern across the codebase:

var mat := DataRegistry.get_material(mat_id)
var hp: float = float(mat.get("terrain_hp", 10.0))
var dn: String = str(mat.get("display_name", mat_id))

The typed wrapper classes follow the same rule internally. MaterialDef, ToolDef, and RecipeDef (in scripts/resources/) each expose a static func from_dict(d: Dictionary) that pulls fields with .get() and a default, so a partial JSON row still produces a valid object:

static func from_dict(d: Dictionary) -> MaterialDef:
    var m := MaterialDef.new()
    m.id = d.get("id", "")
    m.dig_time = d.get("dig_time", 1.0)
    m.terrain_hp = d.get("terrain_hp", 10.0)
    ...
    return m

These wrapper classes capture only a subset of each row's fields. Many live fields — material_tier, trade_value, yield_weights, a tool's max_durability and max_material_tier, a recipe's category and unlock gates — are read directly off the raw dictionary from DataRegistry, never through the *Def object. When in doubt, read the raw dictionary. See Data Schemas Reference.

The Inventory API

Inventory (scripts/resources/Inventory.gd, extends Node) holds the run's material counts in a single resources: Dictionary (material_id -> int) and is the only place stock is mutated.

Method Signature Behaviour
get_amount(id) (String) -> int Count of a material, 0 if absent.
has_enough(id, n) (String, int) -> bool get_amount(id) >= n.
add(id, n, is_reclaim=false) (String, int, bool) -> void Adds stock. Rejects negative amounts with an error.
remove(id, n) (String, int) -> bool Removes n; returns false and leaves stock untouched if insufficient.
can_craft(recipe_id) (String) -> bool True when every input_materials entry is satisfied.

add() refuses negative amounts (a negative would bypass remove()'s sufficiency check) and emits two signals: EventBus.inventory_changed always, and EventBus.inventory_added only when is_reclaim is false — reclaimed gravestone death-loot must not fire the "new gain" signal or lifetime gold stats would double-count recovered stock. remove() short-circuits on has_enough():

func remove(material_id: String, amount: int) -> bool:
    if not has_enough(material_id, amount):
        return false
    resources[material_id] -= amount
    EventBus.inventory_changed.emit(material_id, resources[material_id])
    return true

can_craft() reads the recipe straight from DataRegistry and checks each input:

func can_craft(recipe_id: String) -> bool:
    var recipe := DataRegistry.get_recipe(recipe_id)
    if recipe.is_empty():
        return false
    for material_id in recipe.input_materials:
        if not has_enough(material_id, recipe.input_materials[material_id]):
            return false
    return true

Save integration is direct: get_save_data() returns the resources dict, and load_save_data() rebuilds it, emitting inventory_changed per material so listeners repaint.

Relationship to CraftingUnlockManager

Inventory.can_craft() answers "can I afford it right now"; whether a recipe is visible at all is CraftingUnlockManager's job (scripts/core/CraftingUnlockManager.gd). It tracks seen_materials — every material the run has ever received — and unlocks a recipe once all of its input_materials have been seen at least once:

func _mark_seen(material_id: String) -> void:
    if seen_materials.has(material_id):
        return
    seen_materials[material_id] = true
    _check_unlocks()

It connects to EventBus.inventory_changed (so any stock touch marks a material seen), EventBus.machine_placed, and EventBus.astrolabe_activated, then emits EventBus.recipe_unlocked. Three recipe fields gate this beyond material sight:

The flow is: DataRegistry supplies the recipe definitions, CraftingUnlockManager decides which are unlocked, Inventory.can_craft() decides which unlocked recipes are currently affordable.

Key files