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:

  1. 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.
  2. 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:

ToolState.tool_unlocked.connect(func(tool_id: String) -> void:
    if tool_id == "iron_pickaxe":
        unlock("head_ironhelm"))
for id: String in ["boots_furcuff"]:   # discovery items in the items table
    if not is_owned(id):
        _dig_seeds[id] = _random_dig_tile()

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 the headwear and extra slots to their defaults on a new game (lineage successors keep their look). Face/body slots and the equipped form persist.

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.