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

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:

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