# 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:

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

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

```gdscript
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:

```gdscript
"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()`:

```gdscript
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:

```gdscript
# ⛈️ 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:

```gdscript
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:

```gdscript
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:

```gdscript
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`:

```gdscript
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):

```text
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:

```text
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). |

## Related

- [Challenges](/docs/underroot/challenges)
- [The Artificer's Harrow](/docs/underroot/the-artificer-s-harrow)
- [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow)
- [Adding a Cosmetic](/docs/underroot/adding-a-cosmetic)
- [GameManager and the Run Lifecycle](/docs/underroot/gamemanager-and-the-run-lifecycle)