Cosmetics and Digger Forms

Cosmetics are the customization layer for the digger: skin, hair, beard, headwear, tunic, boots, an extra slot, a class-signature slot, and a full-body "form". scripts/core/CosmeticManager.gd owns ownership and the equipped loadout, every unlock routes through unlock(), and data/cosmetics.json holds the registry. Ownership is account-wide; the equipped loadout is per-run (save v4 for the loadout split, v5 for the class slot — see below).

Ownership in config.json, loadout in the run save

CosmeticManager caches account-level config state in memory. On _ready() it pulls two structures from SaveManager, backed by config.json:

var owned:   Array      = []   # ids the account has unlocked (account-wide)
var loadout: Dictionary = {}   # the new-run TEMPLATE (slot -> item id + colour keys)

Every read goes through the run: get_loadout(), get_effect_mult(), and get_effect_chance() all resolve against _run_loadout(), so gameplay traits and rendering always reflect this slot's digger. One consequence: ledger score rows (save_score) capture the look of the run that died, not whatever another slot equipped since.

After a factory reset, reload_from_config() re-pulls owned and the template from disk; without it, the stale in-memory owned array would resurrect every erased cosmetic on the next write.

_ensure_defaults() fills any missing template slot with that slot's default variant (via DataRegistry.get_default_cosmetic_for_slot(slot)), plus the companion colour keys hair_color, beard_color, and tunic_dye, so a fresh or newly extended config always renders a complete digger.

Ownership rules

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

Free items are always owned. Everything else must be in owned.

All unlocks route through unlock()

Every grant path funnels through one private helper, _grant(item_id), which appends to owned, persists via SaveManager.set_cosmetics_owned(), and emits EventBus.cosmetic_unlocked. It returns false if already owned (so grants are idempotent). Three public entry points wrap it:

Method Toast Used by
unlock(item_id) Generic "New cosmetic unlocked: NAME" Milestones, dig finds, scout, code redemption
grant_reward(item_id) None (caller owns the celebration) Credits reward
_grant(item_id) (internal) None Callers with a tailored toast (_unlock_diadem, _unlock_dye, mastery, Maw-Eaten, class prestige)

Because unlock() is a no-op once owned, re-entering a redemption code or re-hitting a milestone can never double-grant. This is what makes back-filling a cosmetic onto an already-redeemed code farm-safe (see the codes article).

Milestone hooks

_connect_milestones() wires the account-wide unlocks to EventBus signals — cosmetics are never connected node-to-node:

Trigger signal Unlock
astrolabe_activated (1st) head_crown (Astrolabe Crown)
astrolabe_activated (3rd use) head_diadem via _unlock_diadem()
maw_repelled (second repel; first arms a latch) head_horned
population_milestone extra_mantle
new_day with days_survived >= 1 head_clothcap
ToolState.tool_unlocked == "iron_pickaxe" head_ironhelm
skill_leveled (node 7, champion) that class's class_*_prestige via _on_skill_leveled()

Challenge mastery trophies

_check_challenge_mastery(), called from _on_astrolabe_activated(), grants a trophy for each Challenge active when the Ritual fires. The mapping is CHALLENGE_TROPHY:

Challenge id Trophy cosmetic
ravenous_maw head_ravenous
brittle_world head_crackhelm
black_rot head_plaguemask
eye_of_the_storm tunic_oilskin
two_fronts boots_warmarch
lone_villager extra_sash

Each trophy carries a gameplay effect (see below) and a one-line trait blurb in TROPHY_TRAIT_TEXT, read by the mastery cutscene. Newly earned trophies are emitted together via EventBus.challenge_mastered.

The ultimate reward is form_maweaten (The Maw-Eaten), granted by _check_maw_eaten() on EventBus.player_died when the player dies having held all six Challenges and performed at least one Ritual.

Class cosmetics

The class slot (last in slot_order, additive to extra) holds the champion-class signature look — it never competes with the gloves/amulet/mantle in extra. CosmeticManager maps class and skill to the relevant items:

const _CLASS_SIGNATURE := {  # class -> signature (free)
	"miner": "class_ropecoil", "hunter": "class_hunterkit", "smith": "class_apron",
	"mason": "class_toolbelt", "chieftain": "class_pauldrons", "merchant": "class_moneybag",
}
const _CLASS_PRESTIGE := {   # skill -> prestige variant (champion peak)
	"digging": "class_ropecoil_prestige", "foraging": "class_hunterkit_prestige",
	"crafting": "class_apron_prestige", "building": "class_toolbelt_prestige",
	"population": "class_pauldrons_prestige", "trading": "class_moneybag_prestige",
}

See Skill Tree and Classes for the class system itself.

Digger forms

The form slot is a full-body reskin, distinct from the piecewise slots — while a form is equipped it overrides the hat/garment/dye layers visually. Defined forms:

id Name Source
form_none None free (default)
form_maweaten The Maw-Eaten milestone (all-six-Challenge mastery)
form_axel The Axel secret (code)
form_dave The Dave secret (code)
form_hugo The Hugo secret (code)

Secret forms are granted through code redemption, which calls CosmeticManager.unlock().

Cosmetic effects

Cosmetics can carry a generic effect bag, and CosmeticManager aggregates it across every equipped slot of the active run's loadout. Two readers cover the two effect shapes:

func get_effect_mult(key: String) -> float   # product across slots; absent -> 1.0
func get_effect_chance(key: String) -> float  # max across slots; absent -> 0.0

get_effect_mult() multiplies each equipped item's effect[key] (used for dig_speed_mult, food_drain_mult, shelter_fuel_mult, wager_luck_mult, etc.). get_effect_chance() takes the max (used for proc-style traits like ward_bad_ingest_chance). Slot competition — one item per slot — is the natural cap on how many trait multipliers stack. The class signature and prestige items carry no effect — they are cosmetic only; the class's gameplay bonus comes from SkillManager, not the accessory.

Two named wrappers delegate to get_effect_mult() for their call sites, both of them the Berry-picking Gloves' traits:

describe_effect(def) turns an effect bag into player-facing one-liners ("+60% berry picking"). Every surface that advertises a cosmetic reads it — the Your Digger customizer tiles (in the hover tooltip) and Wren's wares board (as a visible line under the item name, since the point of sale must explain itself and the web build has no hover). A new effect key without a line there is invisible to the player even when the hook site honours it.

Equipping and per-run reset

equip(key, value) writes the run loadout (the per-slot truth, persisted by the normal autosave), mirrors the value into the account template (so the next new run inherits the look), persists the template, and emits EventBus.cosmetic_changed.

On a new game, reset_run_equipment() (called from GameManager.reset_for_new_run(), new game only — lineage successors keep their look) resets four slots in that run's loadout only: headwear, extra, and form go bare (to their defaults), and class is seeded to the current calling's signature via _class_signature() (the class is already set by this point). A fresh digger starts bare-headed, without extras, without a suit form — a carried-over form would paint over everything the intro customizer applies — but wearing its calling's signature accessory. The account template and every other slot's look are untouched; face and body slots persist.

data/cosmetics.json structure

The registry has three top-level keys:

{
  "slot_order": ["form","skin","headwear","hair","beard","tunic","boots","extra","class"],
  "items": { "...": { } },
  "dyes":  { "...": { } }
}

slot_order (read by DataRegistry.get_cosmetic_slot_order()) drives both the customizer layout and the effect-aggregation loops. Each entry in items looks like:

"boots_furcuff": {
  "slot": "boots",
  "name": "Fur-cuffed Boots",
  "kind": "style",
  "source": "discovery",
  "effect": {"dig_speed_mult": 1.1}
}
Field Meaning
slot Which slot the item occupies (must be in slot_order)
name Display name
kind color, style, or style_color (whether it carries a colour value)
source free, milestone, discovery, scout, wager, or secret
default true marks the humble starting variant for its slot
trigger For milestone items, the milestone key (e.g. astrolabe3, mastery:black_rot, champion:miner)
effect Optional gameplay-modifier bag
hidden_until_owned Optional; hides the item in the customizer until the account owns it
color For colour/style_color items, the default hex

dyes are simpler {name, color, source, default} entries for the tunic dye picker, read via DataRegistry.get_cosmetic_dye().

hidden_until_owned

hidden_until_owned: true keeps an item out of the customizer grid until it is owned. It is used for the secret forms (form_axel, form_dave, form_hugo), for head_headlamp_gold, and for the six class_*_prestige variants, so unredeemed secret cosmetics and un-earned prestige looks do not hint at their existence in the UI. It does not affect ownership or effects — only visibility.

Single-tile dig finds

A handful of cosmetics are seeded into the world as luck-based digs. reseed_dig_finds() (called on _ready() and on EventBus.generation_started) assigns each un-owned discovery item a single random tile within SEED_X_MIN..SEED_X_MAX / SEED_Y_MIN..SEED_Y_MAX. The seeds are re-rolled each run and never persisted. On EventBus.tile_dug, if the dug tile matches a seed, unlock() (or _unlock_dye() for dye seeds) fires. Current seeds: boots_furcuff, plus the moss and royal dyes.

Key files

File Role
scripts/core/CosmeticManager.gd Ownership, run/template loadouts, unlock routing, milestone hooks, mastery, class signature + prestige, dig finds, effect readers + describe_effect()
data/cosmetics.json Item and dye registry; slot_order (incl. class)
scripts/resources/DataRegistry.gd get_cosmetic, get_cosmetic_dye, get_cosmetic_slot_order, get_default_cosmetic_for_slot
scripts/core/SaveManager.gd Persists cosmetics_owned + the loadout template in config.json; v4 migration seeds run.cosmetics_loadout