Adding a Challenge

Challenges are opt-in run modifiers (meta-progression). Unlike materials or machines, a Challenge is not data-driven — it lives in code in scripts/core/ChallengeManager.gd, and each effect is wired at a hook site that consults an effect-multiplier function. Defining one takes a catalog entry, an unlock gate, run-start effects, and per-frame multipliers.

Where a Challenge lives

The active set for the current run is GameManager.current_run.challenges — an Array of Challenge ids staged before a run and read into the run blob:

"challenges": ChallengeManager.pending_selection.duplicate(),

ChallengeManager reads that array and answers whether a Challenge is active:

func active_ids() -> Array:
    var raw: Variant = GameManager.current_run.get("challenges", [])
    return raw if raw is Array else []

func is_active(id: String) -> bool:
    return id in active_ids()

Step 1 — Add the catalog entry

Add an entry to ChallengeManager.CATALOG, keyed by the Challenge id, and append the id to ORDER (the selection-grid render order), ACCENT (its UI colour), and _COMIC_FILE (its intro image). An existing entry as a template:

"brittle_world": {
    "name": "Brittle World", "icon": "🔨", "tier": "free", "implemented": true,
    "effects": ["Tool durability ×0.5", "Wall HP ×0.5"],
    "unlock_hint": "",
    "backstory": "...",
    "aid_pool": [
        {"id": "stone", "min": 40, "max": 80}, {"id": "clay", "min": 30, "max": 60},
        ...
    ],
},

Catalog fields:

Field Meaning
name / icon Player-facing name and Noto-subset emoji glyph.
tier "free" (selectable as soon as the system unlocks) or "earned".
implemented false shows a locked card whose effects are not wired yet — it can never be selected. Set true only once effects are hooked.
effects The "What changes" lines shown on the card.
unlock_hint Text on a locked earned card (empty for free).
backstory Comic-seed prose; sealed until earned.
aid_pool Day-20 Elder's Aid candidates [{id,min,max}]; delivery rolls 3–4.

Set implemented: true only after every effect line is actually wired. is_selectable() returns false for an unimplemented Challenge, so a half-wired card can appear in the locked grid but Begin will never apply it.

Step 2 — Gate the unlock

The Challenge system unlocks account-wide after the third Ritual, persisted in config.json via SaveManager.mark_challenges_unlocked():

if AstrolabeManager.get_uses_count() >= 3 and not SaveManager.challenges_unlocked():
    SaveManager.mark_challenges_unlocked()

For an earned Challenge, add an endurance/resilience/stacking condition inside _evaluate_earn() (never a depth-based one). Each earned Challenge unlocks when any one of its conditions is met during a Challenge run:

# ⛈️ Eye of the Storm — survive 15 days in a Challenge run.
if days >= 15:
    _earn("eye_of_the_storm")

_earn() records it via SaveManager.add_challenge_earned(id) and toasts. is_selectable() then reveals it:

if str(def.get("tier", "")) == "free":
    return true
return id in SaveManager.get_challenges_earned()

Step 3 — Wire run-start effects

One-time effects (starting stores, population, spawning a second Maw) belong in apply_run_start(), which GameManager.reset_for_new_run() calls after the normal manager resets so these overrides win:

func apply_run_start() -> void:
    if is_active("lone_villager"):
        SurvivalManager.reset(10.0, 10.0, 0.0, 0.0)
        VillageManager.population = 1
        VillageManager.refresh()
    if is_active("two_fronts"):
        MawController.spawn_west_front()

Order matters here — for example the Black Rot root buffer reads the final population, so it runs after Lone Villager and the Harrow souls dial adjust it.

Step 4 — Wire per-frame effect multipliers

Continuous effects are expressed as effect-multiplier functions on ChallengeManager; the relevant system consults the function at its hook site. Each multiplier combines the built-in Challenge value with the Artificer's Harrow dial for the same lever, then clamps to a floor/cap:

func tool_durability_mult() -> float:
    var m := 0.5 if is_active("brittle_world") else 1.0
    return maxf(m * HarrowManager.dial_value("tools"), _FLOOR_DURABILITY)

func maw_storm_mult() -> float:
    if WeatherManager.state != WeatherManager.WeatherState.STORM:
        return 1.0
    var m := 1.75 if is_active("eye_of_the_storm") else 1.0
    m *= lerpf(1.0, 1.75, HarrowManager.dial_t("sky"))
    return minf(m, _CAP_STORM_MAW)

To add a new continuous lever:

  1. Add a multiplier function that returns 1.0 when your Challenge is inactive and the modified value when active. Clamp with a _FLOOR_* / _CAP_* const so stacked/harrowed configs stay disastrous but never degenerate.
  2. Find the system that owns the value (e.g. ToolState durability drain, MawController escalation, WeatherManager intervals) and multiply its base value by ChallengeManager.<your_mult>() at that site.

Existing multipliers to model on: wall_hp_mult, bell_interval_mult, berry_harvest_amount, pond_regen_mult, shelter_fuel_mult, maw_grace_period, maw_learning_mult, pressure_growth_mult, ritual_chew_mult. Reuse an existing lever if your effect matches one — a hook site already consults it.

Step 5 — Trophy cosmetic (optional)

Performing the Ritual with a Challenge active grants a trophy cosmetic. If you want one, add a mapping in scripts/core/CosmeticManager.gd:

const CHALLENGE_TROPHY := {
    "ravenous_maw":     "head_ravenous",
    ...
}

and a matching cosmetics entry (see the cosmetics guide). _check_challenge_mastery() grants it idempotently and feeds the mastery cutscene.

Validate

Challenges are code, so the primary gate is the parse-check (compiles all autoloads, catching type/identifier errors):

powershell -ExecutionPolicy Bypass -File tools\parse_check.ps1

Exit 0 with no SCRIPT ERROR / Parse Error lines is clean. Then run the smoke test to confirm the run blob (including current_run.challenges) survives a save round-trip:

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

If you added a trophy cosmetic (a data change), also run validate_data.gd. Effect feel and card presentation need an in-editor pass.

Key files

File Role
scripts/core/ChallengeManager.gd CATALOG, unlock/earn flow, apply_run_start, effect multipliers.
scripts/core/GameManager.gd Stages challenges into current_run; calls apply_run_start().
scripts/core/CosmeticManager.gd CHALLENGE_TROPHY mastery grants.
scripts/core/SaveManager.gd challenges_unlocked / get_challenges_earned (config.json).