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'smax_durabilityandmax_material_tier, a recipe'scategoryand unlock gates — are read directly off the raw dictionary fromDataRegistry, never through the*Defobject. 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:
unlocked_from_start: true— granted unconditionally at run start via_apply_start_unlocks().unlock_requires_machine: "<machine_type>"— unlocked when that machine is placed, not by materials.unlock_requires_astrolabe_uses: N— held back until the run's ritual count reachesN(checked viaAstrolabeManager.get_uses_count()).
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
scripts/resources/DataRegistry.gd— loads alldata/*.json; typed lookup API.scripts/resources/Inventory.gd— run material counts;add/remove/get_amount/can_craft.scripts/resources/MaterialDef.gd— typed wrapper for a materials row (from_dict).scripts/resources/ToolDef.gd— typed wrapper for a tools row (from_dict,can_dig).scripts/resources/RecipeDef.gd— typed wrapper for a recipes row (from_dict).scripts/core/CraftingUnlockManager.gd— recipe visibility from seen materials, machines, and ritual count.