Adding a Cosmetic
Cosmetics are account-wide, purely visual (or lightly gameplay-modifying) items for the player's digger. They live in data/cosmetics.json, are owned via scripts/core/CosmeticManager.gd, and persist in config.json so they survive slot deletion. Adding one covers the item entry, its unlock source, digger forms, hiding unearned secrets, and tying it to a redemption code.
The cosmetics file
data/cosmetics.json has three sections: slot_order, items, and dyes. A cosmetic item is one entry in items, keyed by its id. Examples:
"head_ironhelm": {"slot": "headwear", "name": "Iron Helm", "kind": "style",
"source": "milestone", "trigger": "iron_pickaxe"},
"boots_furcuff": {"slot": "boots", "name": "Fur-cuffed Boots", "kind": "style",
"source": "discovery", "effect": {"dig_speed_mult": 1.1}},
"form_dave": {"slot": "form", "name": "The Dave", "kind": "style",
"source": "secret", "hidden_until_owned": true,
"effect": {"food_drain_mult": 0.6, "water_drain_mult": 0.6}}
Item fields:
| Field | Meaning |
|---|---|
slot |
One of slot_order: form, skin, headwear, hair, beard, tunic, boots, extra. |
name |
Player-facing name. Name the kind of thing, not just the adjective — the shop row and the customizer tile both print this string raw, so "Berry-picking Gloves" reads where a bare "Gloves" did not. |
kind |
color, style, or style_color (a style that also carries a color). |
source |
How it is obtained: free, milestone, discovery, scout, wager, or secret. |
default |
true marks the humble starting variant for its slot (one per slot). |
trigger |
For milestone items: the milestone id that unlocks it (e.g. iron_pickaxe, astrolabe, mastery:black_rot). |
color |
Hex colour for color / style_color kinds. |
effect |
Optional gameplay-modifier bag (see below). |
hidden_until_owned |
If true, the customizer hides the item until it is owned. |
free items are always considered owned. Every other source must be granted through CosmeticManager before is_owned() returns true:
func is_owned(item_id: String) -> bool:
var def := DataRegistry.get_cosmetic(item_id)
if str(def.get("source", "")) == "free":
return true
return item_id in owned
The effect bag
A cosmetic may carry gameplay modifiers in a generic effect dictionary. Worn items contribute multiplicatively per key via get_effect_mult(), and proc-style chances via get_effect_chance():
func get_effect_mult(key: String) -> float:
var product := 1.0
for slot: String in DataRegistry.get_cosmetic_slot_order():
var item_id: String = str(loadout.get(slot, ""))
if item_id.is_empty():
continue
var eff: Dictionary = DataRegistry.get_cosmetic(item_id).get("effect", {})
product *= float(eff.get(key, 1.0))
return product
Keys in use include dig_speed_mult, food_drain_mult, water_drain_mult, wager_luck_mult, berry_speed_mult, berry_yield_mult, shelter_fuel_mult, maw_learn_mult, chest_gold_mult, and the proc key ward_bad_ingest_chance. One item per slot is the natural cap on stacking.
A new key needs two wirings, and it is silently inert without either:
- A hook site that reads it. Reuse an existing key so an existing site already consults it, or add a
CosmeticManager.get_effect_mult("your_key")read at the value's source. - A line in
CosmeticManager.describe_effect(). That function turns the effect bag into the player-facing one-liners ("+60% berry picking"), and every surface that advertises a cosmetic — the Your Digger tiles, Wren's wares board — words the trait from it. A key with no line there works but is invisible to the player, which for a paid ware means buying blind.
Watch what a multiplier actually multiplies. berry_yield_mult scales the food granted by a bush harvest and deliberately leaves the berries deducted alone: a bush is a 20-berry stock that then rests a full day, so scaling the deduction would strip bushes faster for the same total and the trait would be worth nothing. Where a resource has a stock and a regrow timer, a yield bonus must land on the payout side.
Granting ownership
All grants route through unlock(), which is idempotent (a no-op once owned) and fires a toast:
func unlock(item_id: String) -> bool:
if is_owned(item_id):
return false
if not _grant(item_id):
return false
var display_name := str(DataRegistry.get_cosmetic(item_id).get("name", item_id))
EventBus.show_toast.emit("New cosmetic unlocked: %s" % display_name, Color(0.95, 0.82, 0.30), 4.0)
return true
_grant() appends to owned, persists via SaveManager.set_cosmetics_owned(), and emits EventBus.cosmetic_unlocked. Use grant_reward() instead of unlock() when the caller wants to own the celebration (no generic toast).
Wire your unlock to a trigger:
- Milestone — add a hook in
_connect_milestones()or one of the milestone handlers. For example, the iron helm unlocks when the iron pickaxe is crafted:
ToolState.tool_unlocked.connect(func(tool_id: String) -> void:
if tool_id == "iron_pickaxe":
unlock("head_ironhelm"))
- Discovery (dig find) — add the item id to
reseed_dig_finds(); it is hidden on a random tile each run and unlocked when that tile is dug:
for id: String in ["boots_furcuff"]: # discovery items in the items table
if not is_owned(id):
_dig_seeds[id] = _random_dig_tile()
-
Challenge mastery — map the Challenge id to your cosmetic in
CHALLENGE_TROPHY; performing the Ritual with that Challenge active grants it. -
Scout purchase — add the id to
trades.json → cosmetic_offerswith agold_cost. The wares row prints the item'snameand, beneath it, thedescribe_effectlines, so a bought cosmetic explains itself at the point of sale. See The Villager Economy.
Digger forms
The form slot is a full-body reskin (The Maw-Eaten, The Axel, The Dave, The Hugo). A form is just an items entry with "slot": "form". form_none is the default. Forms commonly carry an effect bag (e.g. form_hugo grants dig_speed_mult: 1.4). Secret forms use "source": "secret" and "hidden_until_owned": true so they never show in the customizer until redeemed.
A fresh digger starts bare-headed with no extra, even if the account owns such gear —
reset_run_equipment()resets only theheadwearandextraslots to their defaults on a new game (lineage successors keep their look). Face/body slots and the equippedformpersist.
Tying a cosmetic to a code
A redemption code can grant a cosmetic. In data/codes.json the reward block's cosmetics array lists item ids, and CodeManager routes each through CosmeticManager.unlock():
var cosmetic_rewards: Array = rewards.get("cosmetics", [])
for cosmetic_id: String in cosmetic_rewards:
var cname := str(DataRegistry.get_cosmetic(cosmetic_id).get("name", cosmetic_id))
if CosmeticManager.unlock(cosmetic_id):
summary.append(cname)
Because cosmetics are account-unique and unlock() is idempotent, a code that gains a new cosmetic later can be re-entered to back-fill only the un-owned one (_grant_new_cosmetics), even after it was already redeemed — without re-granting its farmable tools/materials. See the redemption-code guide for the full flow. The validator checks that every cosmetic id in a code reward exists in cosmetics.json → items.
Validate
After editing data/cosmetics.json, run the data validator (it checks code rewards reference real cosmetic ids, among other things):
<godot console exe> --headless --path . --script res://tools/validate_data.gd
Expect DATA OK. If you added an unlock hook in CosmeticManager.gd, also run the parse-check:
powershell -ExecutionPolicy Bypass -File tools\parse_check.ps1
Parse-check only fully analyses autoloads. A cosmetic touching a scene script (the customizer, the wares board) needs the temp-autoload compile harness — add the script to tools/tmp_compile_check.gd and run it headless.
Then the smoke test for the config round-trip:
cp project.godot project.godot.bak
printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path . # expect SMOKE PASS
cp project.godot.bak project.godot && rm project.godot.bak
Restore project.godot by copying the backup back, never with git checkout --. That file carries uncommitted local tweaks, and a checkout discards them. Restore it immediately after the run either way: a temp autoload left in place gets baked into an export and quits the game at boot.
The digger render (how the cosmetic actually looks) needs an in-editor pass.
Key files
| File | Role |
|---|---|
data/cosmetics.json |
slot_order, items, dyes. |
scripts/core/CosmeticManager.gd |
Ownership, loadout, unlock(), effect aggregation, describe_effect() wording, dig-find seeding, milestone hooks. |
scripts/resources/DataRegistry.gd |
get_cosmetic, get_cosmetic_dye, get_cosmetic_slot_order. |
scripts/core/CodeManager.gd |
Grants cosmetics from redemption codes. |
tools/validate_data.gd |
Verifies code cosmetic ids exist. |