Adding Materials Tools and Recipes

A material, a tool, and the recipe that crafts it all go in JSON under data/, loaded at startup by scripts/resources/DataRegistry.gd. Standard content needs no GDScript. Here are the three edits, then validation.

How the data is loaded

DataRegistry._load_all_data() reads each file into a dictionary keyed by id:

materials = _load_json("res://data/materials.json")
recipes   = _load_json("res://data/recipes.json")
tools     = _load_json("res://data/tools.json")

Every lookup returns {} on a miss, so callers always use .get("key", default):

DataRegistry.get_material(mat_id)   # → Dictionary, {} if unknown
DataRegistry.get_tool(tool_id)      # → Dictionary
DataRegistry.get_recipe(recipe_id)  # → Dictionary

Each entry's top-level key must equal its own id field. The registry keys on the dictionary key, but validation and several call sites read the inner id. Keep them identical.

Adding a material

One entry in data/materials.json, keyed by the material id. A terrain material (coal):

"coal": {
  "id": "coal",
  "first_depth": 27,
  "display_name": "Coal",
  "source_type": "terrain",
  "dig_time": 4.0,
  "gather_time": 4.0,
  "terrain_hp": 30.0,
  "wall_hp": 0.0,
  "base_chew_resistance": 0.0,
  "is_wall_material": false,
  "is_crafting_material": false,
  "is_fuel": true,
  "fuel_seconds": 60.0,
  "work_rate_mult": 1.0,
  "trade_value": 3,
  "durability_cost": 1.0,
  "material_tier": 2,
  "yield_weights": [92, 6, 2]
}

Common fields:

Field Meaning
id Must match the JSON key.
display_name Player-facing name.
source_type terrain, forest, etc. — where it comes from.
terrain_hp / wall_hp HP as a dug tile / as a placed wall block.
base_chew_resistance How hard the Maw finds it to eat.
is_wall_material Eligible for wall building (raw materials, 1 unit per layer).
is_crafting_material Usable as a recipe input.
is_fuel + fuel_seconds + work_rate_mult Fuel value and speed multiplier for machines.
trade_value Villager-economy anchor (iron ≈ 30).
material_tier Tool-tier gating (see below).
durability_cost Base tool-durability drain per dig.
yield_weights Relative weights for the variable-yield roll (TerrainDigging.roll_yield).

Never add a _comment or any _-prefixed key to materials.json. GameManager.compute_completion_pct uses materials.size() as a denominator with no filtering, and the validator fails on meta keys here because they skew the completion percentage.

Adding a tool

One entry in data/tools.json, keyed by the tool id (pickaxe):

"pickaxe": {
  "id": "pickaxe",
  "family": "pickaxe",
  "tier": 1,
  "display_name": "Pickaxe",
  "description": "Standard miner. Breaks stone, coal, iron ore, and sulfur seams...",
  "dig_speed_multiplier": 2.0,
  "gather_speed_multiplier": 1.0,
  "allowed_materials": ["dirt", "clay", "stone", "coal", "iron_ore", "sulfur", "dense_stone"],
  "allowed_resource_nodes": [],
  "max_durability": 80,
  "recipe_id": "pickaxe",
  "max_material_tier": 3
}

Key fields:

Field Meaning
family / tier Groups tiered tools (the Craft menu folds older tiers of a family).
dig_speed_multiplier / gather_speed_multiplier Speed vs. bare hands.
allowed_materials Material ids this tool may dig. Include exactly one tier of overreach.
allowed_resource_nodes Surface node types (e.g. "tree") it can harvest.
max_durability Uses before it breaks. Use -1 for infinite (only hands).
recipe_id The recipe that crafts it (usually the same id).
max_material_tier Highest normal material tier. Digging one tier above costs 4x durability (overreach); two tiers above is disallowed.

Durability of -1.0 means infinite — check > 0 before drawing a durability bar.

Adding the recipe

One entry in data/recipes.json, keyed by the recipe id. A tool recipe:

"pickaxe": {
  "icon": "⛏️",
  "id": "pickaxe",
  "display_name": "Pickaxe",
  "category": "tools",
  "input_materials": {"wood": 3, "stone": 5},
  "output_id": "pickaxe",
  "output_amount": 1,
  "craft_time": 10.0,
  "required_layer": "stone",
  "unlocked_from_start": true
}

Fields:

Field Meaning
category Which menu renders it (see the category rule below).
input_materials {material_id: amount}; every id must exist, every amount > 0.
output_id A material, tool, or machine id.
output_amount Units produced per craft (> 0).
craft_time Base seconds; effective time is divided by balance.crafting.speed_multiplier.
required_layer Layer id the player must have reached (soft flavour gate).
unlocked_from_start If true, unlocked immediately at run start.

The CraftMenu category rule

scenes/ui/CraftMenu.gd builds one collapsible group per category in CATEGORY_ORDER and only these four:

const CATEGORY_ORDER: Array = ["tools", "structures", "machines", "components"]

When it places a recipe it resolves the body by category and silently drops anything with no matching group:

var cat: String = recipe.get("category", "tools")
var body: VBoxContainer = _group_bodies.get(cat)
if body:
    _place_recipe(body, recipe_id, recipe)

So a recipe whose category is not one of those four renders nowhere in the Craft menu. Two more categories are valid but consumed elsewhere, not by CraftMenu:

Category Rendered by
tools, structures, machines, components CraftMenu groups.
processing Machine processing panels (run inside a machine, e.g. glaze_stone).
village BuildMenu storage structures (e.g. craft_well, craft_food_silo).

tools/validate_data.gd enforces exactly this set — any other category value is flagged as having "no consumer":

const VALID_RECIPE_CATEGORIES: Array[String] = [
    "tools", "structures", "machines", "components", "processing", "village",
]

How recipes unlock

scripts/core/CraftingUnlockManager.gd gates visibility. A recipe unlocks when every one of its input_materials has been seen at least once. "Seen" means the material passed through inventory — EventBus.inventory_changed marks it via _mark_seen(), and _check_unlocks() then unlocks any recipe whose inputs are all seen:

var inputs: Dictionary = def.get("input_materials", {})
var all_seen := true
for mat_id: String in inputs.keys():
    if not seen_materials.has(mat_id):
        all_seen = false
        break
if all_seen and _astrolabe_gate_open(def):
    unlocked_recipes[recipe_id] = true
    EventBus.recipe_unlocked.emit(recipe_id)

Practical consequences when adding a recipe:

Validate

After editing any data/*.json, run the data validator (it checks recipe categories, that input/output ids resolve to real materials/tools/machines, positive amounts, and BOMs):

<godot console exe> --headless --path . --script res://tools/validate_data.gd

It passes when the last line is DATA OK. For a new material/tool/recipe it will catch a mistyped input id, a missing output_id, a non-positive amount, or an unknown category.

Then run the smoke test to confirm the save round-trip and offline sim still hold over the real autoload stack:

printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path .        # expect SMOKE PASS
git checkout -- project.godot

The parse-check (tools\parse_check.ps1) only compiles scripts, so it will not catch data errors — always run validate_data.gd after a JSON edit.

Key files

File Role
data/materials.json Material definitions.
data/tools.json Tool definitions (durability, allowed materials, tiers).
data/recipes.json Recipe definitions (inputs, output, category).
scripts/resources/DataRegistry.gd Loads all data files; get_material/tool/recipe.
scripts/core/CraftingUnlockManager.gd Unlocks recipes once all input materials are seen.
scenes/ui/CraftMenu.gd Renders tools/structures/machines/components groups only.
tools/validate_data.gd Data validator (categories, id references, amounts, BOMs).