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)
owned—SaveManager.get_cosmetics_owned()/set_cosmetics_owned(), stored undercosmetics_owned. Account-wide, survives slot deletion: cosmetics are permanent progression.loadout— sinceSAVE_VERSION = 4this is only the new-run template: the look a fresh run inherits. The active run's truth iscurrent_run["cosmetics_loadout"], reached exclusively through the private_run_loadout()accessor, which lazily seeds it from the template on first touch (new runs; migrated saves are seeded by_migrate()'s v4 step; the title screen's default run dict is harmless — it is rebuilt on play).
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",
}
- Signature accessory — one per class,
source: free(always equippable, removable in the customizer): Miner's Rope, Hunter's Kit, Smith's Apron, Mason's Tool-Belt, Chieftain's Pauldrons, Merchant's Purse._class_signature()resolves the current calling's id (or theclassdefault for a classless run). It is auto-seeded on new game (reset_run_equipment) and re-pointed on a carry-on calling switch (reseed_class_signature, called fromGameManager.switch_calling). - Prestige variant —
class_*_prestige(gilded + glow,hidden_until_owned,trigger: champion:<class>), granted account-wide at champion peak._on_skill_leveled(skill, node)fires the_grantwhennode >= 7andSkillManager.is_champion(skill)(node 7 is champion-only, so the node check alone nearly implies it), with a tailored "Champion peak!" toast.
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:
get_berry_speed_mult()— dividesBerryBush.GATHER_TIMEinGatherController, captured once when a pick starts so swapping gloves mid-pick cannot retroactively speed it up.get_berry_yield_mult()— scales the food a bush harvest grants, inForestManager.harvest_bush(). It deliberately does not scale the berries deducted from the bush: 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. Food is a float on the supply bar, so the multiplier applies with no rounding loss. This is the trait that matters at scale — bush stock and regrowth are the real ceiling on berries, not pick speed.
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 |