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
_commentor any_-prefixed key tomaterials.json.GameManager.compute_completion_pctusesmaterials.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:
- If you want it available immediately, set
"unlocked_from_start": true(handled by_apply_start_unlocks()). - Otherwise the player must have obtained each input material at least once before the recipe appears.
- Two extra gates exist:
unlock_requires_machine(unlocks when that machine type is placed, via_on_machine_placed) andunlock_requires_astrolabe_uses(a positive ritual-count gate). Do not combineunlock_requires_astrolabe_useswithunlocked_from_start— the start-unlock path never checks the ritual gate, so it would silently bypass it (the validator fails this combination).
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 runvalidate_data.gdafter 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). |