The Artificer's Harrow

The Artificer's Harrow is Underroot's design-your-own challenge — the seventh Challenge card. Instead of picking a pre-built modifier, the player turns a set of severity dials and optionally folds in the built-in Challenges, producing a custom run configuration that can be shared as a code. It is implemented in scripts/core/HarrowManager.gd; its effect math is applied in scripts/core/ChallengeManager.gd.

Relationship to the Astrolabe: the Harrow is unlocked through endgame progression (the Artificer is the fourth Astrolabe specialist), but mechanically it belongs to the Challenge system, not the ritual. See The Astrolabe for the ritual device and Challenges for the built-in modifiers it composes with.

Unlocking the Harrow

The Harrow is account-wide and gated behind mastering every built-in Challenge — that is, owning all six Challenge mastery trophies (CosmeticManager.CHALLENGE_TROPHY):

func is_unlocked() -> bool:
	for id: String in CosmeticManager.CHALLENGE_TROPHY.keys():
		if not CosmeticManager.is_owned(str(CosmeticManager.CHALLENGE_TROPHY[id])):
			return false
	return true

Because the gate keys off cosmetic ownership, it survives run resets and save restores like any other account unlock. When the sixth trophy lands, _maybe_announce_unseal() (connected to EventBus.challenge_mastered) fires a one-time toast in the Artificer's orange accent, guarded by SaveManager.harrow_announced() so it never repeats.

Anatomy of a Harrow config

A Harrow configuration is a plain dictionary:

{harrow: true, version, name, author, includes: [...challenge_ids], dials: {dial_id: t}}

The active config for a run lives in GameManager.current_run["harrow"]. HarrowManager owns the dial catalog, the unlock gate, config hygiene, and the codes; the effect math lives in ChallengeManager — the Harrow only supplies severity values.

The dial catalog

DIALS defines twelve dials, grouped into three clusters (CLUSTERS) that mirror the Harrow screen. Every dial is harder-only by construction: t = 0 is always the baseline, and turning it toward t = 1 only ever makes the run worse. Each dial has a kind:

Cluster 1 — The Maw and the World

Dial Name Kind Range (base → worst) What it adjusts
grace The Waking abs 360 s → 30 s Grace period before the Maw first stirs (maw_grace_period).
hunger The Hunger mult ×1.0 → ×4.0 Long-term pressure escalation rate (pressure_growth_mult). At full turn it also lifts the pressure ceiling by up to +2.0 (pressure_max_bonus, ×5 → ×7).
learning The Learning mult ×1.0 → ×3.0 How fast the Maw grows familiar with materials it eats (maw_learning_mult).
ritual_fury The Reckoning mult ×1.0 → ×2.5 Permanent chew added per Astrolabe ritual (ritual_chew_mult).
surges The Frenzy composite +0% → +100% Surge frequency (surge_interval_mult ×1 → ×0.3) and surge strength (surge_boost_mult ×1 → ×1.667).
sky The Sky Turns composite +0% → +100% Storm-time Maw speed (maw_storm_mult ×1 → ×1.75), rain frequency (rain_interval_mult ×1 → ×0.1) and storm frequency (storm_interval_mult ×1 → ×0.2).

Cluster 2 — Your Works Fail You

Dial Name Kind Range (base → worst) What it adjusts
tools Green Iron mult ×1.0 → ×0.4 Tool durability (tool_durability_mult, floored at _FLOOR_DURABILITY).
walls Bad Mortar mult ×1.0 → ×0.4 Wall HP (wall_hp_mult, floored at _FLOOR_WALL_HP).

Cluster 3 — The Village Is Fragile

Dial Name Kind Range (base → worst) What it adjusts
souls The Few abs 47 → 1 Starting population; stores thin along with it (applied at run start).
appetite The Appetite mult ×1.0 → ×2.0 Food and water drain per villager (food_drain_mult, water_drain_mult).
cold The Cold mult ×1.0 → ×2.0 Shelter fuel burn rate (shelter_fuel_mult).
meager Meager Earth composite +0% → +100% Gathering-bell interval (bell_interval_mult ×1 → ×2), berries per pick (down to 1), and pond seep-back (pond_regen_mult ×1 → ×0.25).

How a dial becomes an effect

Every effect site in ChallengeManager folds its built-in Challenge value together with the matching Harrow dial, so a Harrow run and a Challenge run flow through exactly one code path. The pattern (from ChallengeManager):

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 pressure_growth_mult() -> float:
	var m := 3.0 if is_active("ravenous_maw") else 1.0
	return minf(m * HarrowManager.dial_value("hunger"), _CAP_PRESSURE)

dial_value(id) returns lerp(base, worst, t) for mult/abs dials (or the untouched base when the dial is at zero, which makes it the identity). composite dials expose dial_t(id) instead, and each consumer lerps its own knob — that is how one dial (meager, sky, surges) drives several levers at once. The souls dial is special: it has no per-tick accessor and is applied imperatively in apply_run_start(), which runs before Black Rot's population-derived root buffer so the reduced headcount propagates correctly.

Because dials multiply on top of Challenge multipliers and every site clamps to a floor or cap (_FLOOR_DURABILITY, _CAP_PRESSURE, …), a maxed-out Harrow that also includes the matching Challenge stays punishing but never degenerates into divide-by-zero territory.

Including built-in Challenges

Beyond the dials, a config's includes array names built-in Challenges whose full mechanical effects stack on the run. sanitize() filters these against ChallengeManager.ORDER and de-duplicates them, so only real Challenge ids survive. In effect a Harrow can start from, say, Two Fronts and then push individual dials past what that Challenge alone would do.

Config hygiene and anti-cheat

sanitize(cfg) is the single funnel every external config passes through — redemption codes, the design library, and the Harrow UI all call it. It:

Because sanitization is harder-only, a hand-tampered code cannot sneak a dial below baseline to gain an advantage and still post scores — the clamp discards it.

Sharing: Harrow codes

A config round-trips as a UROOT1- code (the same encoding as save codes: JSON → UTF-8 → deflate → base64, prefixed with SaveManager.SAVE_CODE_PREFIX):

func export_code(cfg: Dictionary) -> String:
	var clean := sanitize(cfg)
	if clean.is_empty():
		return ""
	var compressed := JSON.stringify(clean).to_utf8_buffer().compress(FileAccess.COMPRESSION_DEFLATE)
	return SaveManager.SAVE_CODE_PREFIX + Marshalls.raw_to_base64(compressed)

decode_code(code) reverses it and passes the result back through sanitize(), so a malformed or non-Harrow payload decodes to {}.

Applying a Harrow to a run

The Harrow screen's Begin button stages the chosen config on HarrowManager.pending_config. GameManager.reset_for_new_run() reads it into current_run["harrow"] and then clears it — mirroring how ChallengeManager.pending_selection becomes current_run["challenges"]. From that point is_harrow_run(), dial_t(), and dial_value() all read the active run's config.

Verification

tools/harrow_check.gd is a headless logic gate, run as a temporary autoload (ZZHarrowCheck). It exercises sanitize (tampered payloads dropped, over-range clamped, sub-baseline discarded), code round-tripping, the active-config queries, ChallengeManager effect stacking (dial × included Challenge, then floors/caps), and the run-start souls scaling. It prints HARROW OK / HARROW FAIL and quits with a matching exit code.

Key files

File Role
scripts/core/HarrowManager.gd Dial catalog (DIALS, CLUSTERS), unlock gate, sanitize/export_code/decode_code, pending_config.
scripts/core/ChallengeManager.gd The effect sites that fold each dial into its lever.
scripts/core/CosmeticManager.gd CHALLENGE_TROPHY ownership — the unlock gate.
scenes/ui/HarrowScreen.gd The dial UI; stages pending_config on Begin.
tools/harrow_check.gd Headless Harrow logic gate (HARROW OK / HARROW FAIL).