Challenges
Challenges are opt-in run modifiers (meta-progression) that make a run harder in exchange for mastery rewards. scripts/core/ChallengeManager.gd stores the active set, exposes effect multipliers, and gets consulted by hook sites across the codebase.
Overview
The ChallengeManager autoload owns three things:
- The catalog the selection UI renders (
CATALOG,ORDER,ACCENT, comic art). - The active set for the current run, read from
GameManager.current_run.challenges. - The effect-multiplier accessors that every hook site consults to apply a Challenge's mechanical changes.
The active set is an Array of Challenge ids. ChallengeManager doesn't own that array; GameManager does, inside current_run, so it rides the save blob and survives save-load like any other run state. ChallengeManager only reads it.
The catalog
Every Challenge is a key in the CATALOG dictionary. Six are defined and rendered in ORDER:
| id | Name | Tier | Effects (summary) |
|---|---|---|---|
lone_villager |
The Lone Villager | free |
Population starts at 1; gathering bell 2x slower; begin with 10 food/10 water; berries pick 1 at a time; pond starts near dry and seeps back slowly |
brittle_world |
Brittle World | free |
Tool durability x0.5; wall HP x0.5 |
eye_of_the_storm |
Eye of the Storm | earned |
Near-constant rain plus random storms; storms speed the Maw x1.75; shelter fuel x2 |
ravenous_maw |
The Ravenous Maw | earned |
Grace period 360s to 60s; Maw learns x3; escalation x3; ritual chew increase x2 |
black_rot |
The Black Rot | earned |
1 strange root per villager per day, or they die; starting root buffer plus boosted root trade |
two_fronts |
Two Fronts | earned |
A second Maw crosses in from the west, with its own outskirt wall line and breach |
Each catalog entry carries name, icon (an emoji glyph from the bundled Noto subset), tier, implemented, effects, unlock_hint, backstory, and aid_pool. Two fields gate behavior:
tieris"free"(selectable the moment the system unlocks) or"earned"(must be unlocked by playing).implementedisfalsefor a catalog entry whose effects are not wired yet; such a Challenge shows on the locked grid but can never be selected.
ACCENT maps each id to a thematic hex accent colour; resolve it with accent_color(id) (returns a Color, defaulting to #9a8f6e on miss). comic_path(id) returns the res:// path to a Challenge's one-page comic intro, or "" if none.
The unlock flow
The whole system is gated behind the Astrolabe (referred to only as "the Ritual" in player-facing copy). In _on_ritual_performed() — connected to EventBus.astrolabe_activated — once AstrolabeManager.get_uses_count() >= 3, ChallengeManager calls SaveManager.mark_challenges_unlocked() and announces the unlock with fireworks in the six accent colours plus a toast. The unlocked flag lives account-wide in config.json (SaveManager.challenges_unlocked()).
Earned Challenges unlock individually through _evaluate_earn(), connected to EventBus.new_day and EventBus.generation_started. Conditions are endurance / resilience / stacking based, never depth. Each earned Challenge unlocks when ANY one of its conditions is met during a Challenge run (a run with at least one active Challenge):
| Challenge | Earn condition |
|---|---|
eye_of_the_storm |
Survive 15 days |
ravenous_maw |
Survive 20 days, or grow a lineage of 3 |
black_rot |
Survive 14 days with 2 or more Challenges stacked |
two_fronts |
Survive 25 days, or perform 3 Rituals |
_earn(id) records the earn via SaveManager.add_challenge_earned(id) (persisted in config.json under challenges_earned) and toasts once. is_selectable(id) returns true for an implemented free Challenge, or for an earned Challenge whose id is in SaveManager.get_challenges_earned().
From selection to active run
The selection overlay stages the player's chosen ids on ChallengeManager.pending_selection (an Array[String]). GameManager.reset_for_new_run() reads that into current_run.challenges, then clears it. From that point the active set is queried with:
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()
Effect multipliers and hook sites
Each mechanical change is exposed as an accessor that a hook site elsewhere in the codebase multiplies into its own value. The pattern is uniform: the accessor returns 1.0 (or the neutral value) when the Challenge is inactive, and its Challenge value when active. Each accessor also folds in the matching Artificer's Harrow dial (HarrowManager.dial_value / dial_t), which is the identity when no harrow rides the run, and clamps the product to a floor or cap so stacked configs stay disastrous but never degenerate.
Representative accessors:
| Accessor | Backing Challenge | What it scales |
|---|---|---|
tool_durability_mult() |
brittle_world |
Tool durability (x0.5), floor _FLOOR_DURABILITY = 0.2 |
wall_hp_mult() |
brittle_world |
Wall HP (x0.5), floor _FLOOR_WALL_HP = 0.2 |
bell_interval_mult() |
lone_villager |
Gathering-bell interval (x2), cap _CAP_BELL = 5.0 |
berry_harvest_amount() |
lone_villager |
Berries per pick (returns 1 when active) |
pond_regen_mult() |
lone_villager |
Pond seep-back rate (x0.25) |
shelter_fuel_mult() |
eye_of_the_storm |
Shelter fuel burn (x2) |
maw_storm_mult() |
eye_of_the_storm |
Maw speed, only while WeatherManager.state == STORM (x1.75), cap 2.5 |
rain_interval_mult() / storm_interval_mult() |
eye_of_the_storm |
Gaps WeatherManager schedules between rain / storms |
maw_grace_period(base) |
ravenous_maw |
Absolute grace seconds (60s when active) |
maw_learning_mult() |
ravenous_maw |
MawAdaptation familiarity growth (x3), cap 6.0 |
pressure_growth_mult() |
ravenous_maw |
Long-term pressure escalation rate (x3), cap 6.0 |
ritual_chew_mult() |
ravenous_maw |
Permanent chew added per Ritual (x2), cap 4.0 |
A few accessors (food_drain_mult(), water_drain_mult(), surge_interval_mult(), surge_boost_mult()) have no backing Challenge — they are Harrow-only levers and return the identity unless a harrow is active.
Because the multipliers compose by multiplication and are individually clamped, a hook site should call the accessor once per tick and multiply — never re-derive the Challenge condition itself. Adding a new hook means adding an accessor here, not an
is_active()check at the call site.
Run-start application
Some Challenges need imperative setup that a multiplier cannot express. apply_run_start() is called from GameManager.reset_for_new_run() after the normal manager resets, so its overrides win:
lone_villager—SurvivalManager.reset(10, 10, 0, 0),VillageManager.population = 1, andForestManager.pond_water = 10.black_rot— adds astrange_rootbuffer ofpopulation * 2(read after Lone Villager and the souls dial so a 1-pop run gets 2 root).two_fronts— callsMawController.spawn_west_front()to append the western Maw afterfronts[0](east) already exists.
Day-20 Elder's Aid
On the first day-20 of a Challenge run, _maybe_deliver_aid() (connected to EventBus.new_day) delivers one care package per active Challenge, rolled from that Challenge's aid_pool via _roll_aid_package() (3–4 distinct entries, each quantity in its [min, max]). Food and water route through SurvivalManager.add_supplies(); everything else through Inventory.add(). The package is deposited immediately and gated single-fire by the challenge_aid_delivered flag on current_run, so it survives save-load and resets with the run. EventBus.challenge_aid_offered drives the ceremony modal.
Mastery rewards
Performing the Ritual with a Challenge active earns that Challenge's trophy cosmetic. That flow lives in scripts/core/CosmeticManager.gd (CHALLENGE_TROPHY, _check_challenge_mastery()), which reads ChallengeManager.active_ids() and ChallengeManager.CATALOG. See the cosmetics article for detail.
Key files
| File | Role |
|---|---|
scripts/core/ChallengeManager.gd |
Catalog, active-set queries, effect multipliers, unlock/earn flow, day-20 aid, run-start application |
scripts/core/GameManager.gd |
Owns current_run.challenges; calls apply_run_start() from reset_for_new_run() |
scripts/core/SaveManager.gd |
Persists challenges_unlocked and challenges_earned in config.json |
scripts/core/CosmeticManager.gd |
Challenge mastery trophies |