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}}
harrow: true— payload self-declaration, so the config can be routed like any other shareable code.name/author— free text, each capped atNAME_MAX_LEN(24 chars).includes— ids of built-in Challenges (fromChallengeManager.ORDER) whose full effects stack on top of the dials.dials—dial_id -> t, where severitytis in(0, 1]. A dial att = 0is baseline and is omitted entirely.
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:
mult—dial_value()lerpsbase → worstbyt; the result multiplies the matching Challenge lever.abs— an absolute value (a duration or a headcount) consumed vialerp/minat its effect site.composite— one named idea driving several knobs; consumers readdial_t()and apply their own lerps.
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:
- rejects anything that is not a
harrow: truepayload (returns{}), - clamps every severity into
(0, 1]and drops non-numeric or sub-baseline (t <= 0) dials, - filters and de-duplicates
includesagainstChallengeManager.ORDER, - caps
name/authoratNAME_MAX_LEN, and - returns
{}when the result configures nothing (no dials and no includes).
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). |