The Astrolabe
The Terrestrial Astrolabe is an endgame ritual device. Each activation permanently escalates the Maw, seeds exotic materials into the world, and wipes your machines — an irreversible trade you make on purpose. The Artificer's Harrow is the design-your-own challenge that the Astrolabe's final specialist unlocks. The code lives in scripts/core/AstrolabeManager.gd, the four specialists in scripts/village/SpecialistManager.gd, and scripts/core/HarrowManager.gd.
Building the Astrolabe: the four specialists
The Astrolabe is gated behind four specialists who arrive in a fixed order, each triggered by a different milestone. SpecialistManager (scripts/village/SpecialistManager.gd) owns the arrival state, which lives in GameManager.current_run["specialists_arrived"] so it saves and restores with the run.
| Order | Specialist | Trigger | Signal handler |
|---|---|---|---|
| 1 | The Wandering Archivist | The village bell rings | _on_village_bell_rang |
| 2 | The Stonewarden | A tile is dug at the quartz layer depth |
_on_tile_dug |
| 3 | The Assayer | Any material in EXOTIC_IDS enters inventory |
_on_inventory_changed |
| 4 | The Artificer | Population reaches ARTIFICER_POPULATION, all three others present |
_on_population_changed |
EXOTIC_IDS for the Assayer trigger is ["ember_essence", "void_iron", "ancient_clay", "prismatic_shard"].
The Artificer population gate
The Artificer is the slowest gate in the run. Its threshold is read from data/balance.json -> village -> artificer_population (default 120), and the arrival additionally requires the other three specialists to have arrived first:
func _on_population_changed(new_pop: int) -> void:
if has_arrived("artificer"):
return
if new_pop < ARTIFICER_POPULATION:
return
if not has_arrived("archivist") or not has_arrived("stonewarden") or not has_arrived("assayer"):
return
_arrive("artificer")
CLAUDE.md notes the Artificer gate as
village.artificer_populationand the constant default as 120; both the balance file andSpecialistManager.ARTIFICER_POPULATIONagree at 120. (TheDIALScatalog inHarrowManageralso references 47 as the baseline village population for its "souls" dial, which matchesvillage.base_population.)
Each _arrive() appends to specialists_arrived, emits EventBus.specialist_arrived, writes a gold journal narrative, and (after a short defer) shows a walk-in popup panel with the specialist's portrait, body text, and a hint pointing at the next arrival. all_arrived() reports whether every specialist in SPECIALIST_DATA has come.
The ritual cutscene trigger
Placing the third Binding Stake in the world (not crafting it) fires the Astrolabe cutscene. AstrolabeManager._on_machine_placed listens to EventBus.machine_placed:
if has_astrolabe() and count_binding_stakes() >= 3:
GameManager.current_run["astrolabe_cutscene_seen"] = true
EventBus.astrolabe_cutscene_triggered.emit()
The astrolabe_cutscene_seen run flag guards against replay: machine_placed also fires as a save loads its stakes, and current_run is restored before machines, so the flag is already correct by the time load-time signals arrive. A separate astrolabe_components_modal_seen flag drives a one-time celebration modal the first time the Astrolabe itself is built.
can_activate() returns true when has_astrolabe() and count_binding_stakes() >= 3.
The three sliders
activate() is called by the Astrolabe panel with three slider values:
| Slider | Range | Meaning |
|---|---|---|
slider_volume |
0.0–1.0 |
Tile restore volume (maps to 10%–90% of dug tiles refilled) and the primary chew-rate increase. |
slider_discovery |
-1.0–1.0 |
World-wide discovery density (-50%–+50%); also adds to the chew cost. |
slider_depth |
-1.0–1.0 |
Shallow vs deep bias for restoration; deep focus degrades strange-root defense. |
Permanent Maw escalation (astrolabe_chew_mult)
Every activation stacks a permanent chew-rate multiplier stored in current_run["astrolabe_chew_mult"]. get_chew_rate_mult() reads it (default 1.0), and it is consumed by the Maw's live tick, breach ETA, and offline sim alike (see The Maw). Despite its name, compute_chew_increase() returns a growth rate, not an addend:
func compute_chew_increase(slider_volume: float, slider_discovery: float) -> float:
var disc_norm := (slider_discovery + 1.0) * 0.5 # 0.0–1.0
var disc_cost := CHEW_INCREASE_DISCOVERY_MIN + disc_norm * (CHEW_INCREASE_DISCOVERY_MAX - CHEW_INCREASE_DISCOVERY_MIN)
var base_increase := CHEW_INCREASE_BASE + slider_volume * CHEW_INCREASE_VOLUME + disc_cost
return base_increase * ChallengeManager.ritual_chew_mult()
activate() then compounds it:
var new_mult := get_chew_rate_mult() * (1.0 + compute_chew_increase(slider_volume, slider_discovery))
Growth per ritual is ×1.30 at min sliders, ×1.465 at mid, ×1.63 at max. The stack is deliberately uncapped — the run ends when the player dies, not at a ceiling, and nothing downstream needs a bound because each front consumes at most one wall column per frame regardless of rate.
This was additive (
stack + increase) until the compounding pass. Additively the curve decelerated — at mid sliders ritual 1 cost +82% of standing threat and ritual 9 cost +11% — so the rituals meant to be the most dangerous decision were the cheapest. The four constants were rescaled at the same time; a growth rate needs smaller numbers than an addend.
The ChallengeManager.ritual_chew_mult() hook (the Ravenous Maw challenge, the Harrow's Reckoning dial) is applied inside compute_chew_increase — the single source — so the panel's cost preview and the real activation agree. Consequence tuning defaults live in AstrolabeManager and are overridden by data/balance.json -> astrolabe:
| Constant | Balance key | Default |
|---|---|---|
CHEW_INCREASE_BASE |
chew_increase_base |
0.08 |
CHEW_INCREASE_VOLUME |
chew_increase_volume |
0.18 |
CHEW_INCREASE_DISCOVERY_MIN |
chew_increase_discovery_min |
0.22 |
CHEW_INCREASE_DISCOVERY_MAX |
chew_increase_discovery_max |
0.37 |
EXOTIC_CHANCE_PER_USE |
exotic_chance_per_use |
0.02 |
EXOTIC_CHANCE_CAP |
exotic_chance_cap |
0.20 |
Calibration. The four chew constants are solved backwards from one anchor: after 12 max-slider rituals the Maw eats a full 9-stack glazed_vault_block column (45,000 HP, resistance 7.0) in ~60 s, against MawAdaptation.MAX_MULTIPLIER familiarity at MawController.PRESSURE_MAX:
45000 / 60 s = 750 HP/s -> needs stack x350 -> 350^(1/12) = x1.63 per ritual
tools/astrolabe_curve_check.gd asserts that target against the live balance.json and prints the 12-ritual table (CURVE OK / CURVE FAIL). It mirrors the formula rather than calling it — autoloads are unavailable to --script runs — so a formula change needs updating in both places.
Presenting the cost to the player
Two preview call sites mirror the compounding arithmetic and will quote a false price if they drift: MachineController._refresh_ritual_preview (the slider panel's "Maw speed after" consequence row) and the fire-confirm modal, both current_mult * (1.0 + compute_chew_increase(...)).
Both show the growth rate next to the absolute before/after:
Maw speed after ×3.14 (now ×2.20 — grows 46%)
Maw speed: ×2.20 → ×3.14 (permanent, +46%)
That percentage is not decoration — keep it. Two absolutes alone read as a flat "+0.94" and invite a linear extrapolation, which was harmless while the stack was additive and is wrong by an order of magnitude now (at ritual 9, max sliders, the real jump is +31, not +1.25). The rate is constant for a given slider position however many rituals have been fired, so a player who fires max sliders repeatedly sees "grows 63%" every time and learns the rule by repetition.
MachineController._fmt_chew_mult() sheds decimals above ×10 and ×100 — the consequence row has ~180px inside a 300px panel and the stack compounds into the hundreds. The Hunger ×N readout in MawBar / MawMedallion applies the same rule for the same reason.
Binding Stakes (strange root defense degrades)
The ritual also degrades the strange-root bounce defense via the depth slider. depth_factor = (slider_depth + 1.0) * 0.5 (0 shallow, 1 deep):
- Bounce duration shrinks:
astrolabe_bounce_durloses up toBOUNCE_LOSS_MAX(8 s), floored atMIN_BOUNCE_DURATION(5 s). Read viaget_bounce_duration(), base 30 s. - Bounce cooldown grows:
astrolabe_bounce_cdgains up toBOUNCE_CD_INCREASE_MAX(0.5), capped atMAX_BOUNCE_COOLDOWN(4.0). Read viaget_bounce_cooldown_mult().
These feed the Maw's repel() (stun duration) and consume_tile() (column cooldown) respectively.
The Maw wall jump
activate() emits EventBus.astrolabe_maw_jump.emit(wall_pct) where wall_pct = RESTORE_PCT_MIN + slider_volume * RESTORE_PCT_SPAN. MawController handles this by breaching that fraction of each front's own standing wall volume, eaten from the Maw side inward — the Maw is deliberately not lunged forward, so firing the ritual can never be an instant breach. In a Two Fronts run the east wall gives immediately and each other front's wall breaches after a randomised delay (GameConstants.ASTROLABE_WEST_BREACH_DELAY_MIN/MAX, 3-10 s). See The Maw for _breach_front_walls.
Machine wipe
Every ritual wipes machines through _wipe_machines(): the Astrolabe and all Binding Stakes are destroyed entirely (consumed by the ritual), duplicates of every other machine type are destroyed (one survivor kept per type), and survivors have their fuel wiped and are deactivated. Removal routes through MachineManager.remove_machine() so the _occupied_tiles spatial index is cleared too — a bare machines.erase() would leave ghost footprints that block all future placement.
The Astrolabe Core and Lens are one-per-run inventory tokens, not placed machines, so they survive the wipe. Owning a Core installs the permanent x2 crafting boon; owning a Lens enables machine relocation and parallel crafting.
has_core()/has_lens()checkInventory.get_amount(...) > 0.
Survivors are neither filled in nor walled off
The wipe runs before the tile restore, so whatever is still standing has to survive the refill. Two guarantees hold:
- Never filled in.
_restore_tiles()passesMachineManager.occupied_tiles()to the planner as an exclusion set, and re-checks it at the write site as it applies the plan. The planner's exclusion alone was advisory — nothing downstream enforced it — so the guarantee now holds regardless of what the planner decides. - Never sealed in. Excluded tiles were originally kept out of the fill without being kept reachable, so a restore could pack every route solid and leave a surviving machine in a bubble: its own tiles open, no way in. The
count >= totalbranch did this to every machine by construction.WorldManager._spare_protected_access()now walks out from any protected tile that has lost its last open neighbour, breadth-first through the planned fill, and un-fills the shortest corridor back to open ground — or to daylight, which is the only thing left to reach when the restore is filling everything. It costs a handful of tiles out of the restore volume; machines sitting in open tunnels short-circuit before the search starts.
Exotic seeding
After the first activation, astrolabe_exotics_seeded is set and exotics begin appearing through two disjoint paths:
- Restored caverns (random).
_restore_tiles()refills dug tiles chosen byWorldManager.plan_connectivity_safe_fill(), which honours the depth bias, never seals a cavern off from the surface, and protects standing machines (above). Each refilled tile has a chanceEXOTIC_CHANCE_PER_USE * uses(capped atEXOTIC_CHANCE_CAP, scaled by a depth multiplier) to be upgraded to an exotic via_pick_exotic_for_depth(). - Undug ground (deterministic, Path B).
roll_exotic_for_tile(x, y)is called fromLayerGenerator.generate_tilefor every solid tile. It is stable per(x, y, uses)usingEXOTIC_TILE_SALT/EXOTIC_PICK_SALThashes, costs nothing until the first ritual (uses == 0returns""), and only fires within the exotic depth band (EXOTIC_DEPTH_FLOOR20 toEXOTIC_DEPTH_CEIL320).
Exotic materials and their approximate tile-Y depth ranges live in EXOTIC_SEED_MATERIALS and EXOTIC_DEPTH_RANGES; EXOTIC_SEED_WEIGHTS biases the weighted pick so machine-gating mats appear reliably without starving the rarer astrolabe-exclusive endgame exotics.
Note the deliberate asymmetry with the chew curve: the exotic chance flattens at EXOTIC_CHANCE_CAP from ritual 10, while the chew stack compounds without limit. Rituals past 10 buy discovery density and refilled cavern at a steeply rising price — which is what makes stopping a real decision.
Discovery density (compounding)
Each ritual permanently scales world-wide discovery rates by 1.0 + slider_discovery * 0.5 (0.5x–1.5x), clamped between DISCOVERY_MULT_MIN (0.25) and DISCOVERY_MULT_MAX (2.5) so repeated rituals cannot reach absurd extremes. Max slider reaches the ceiling by about the third ritual (1.5^3 clamps down to 2.5); rituals beyond that pay off in exotic seeding and Maw hunger, not more discovery density. Read via get_discovery_mult() and applied at dig time in DiscoveryManager. discovery_mult_for(def) excludes "bad" discovery types (DENSITY_EXCLUDED_REWARD_TYPES: maw_resonance, plague_spore, hazard) — a denser world never means more resurgences, plagues, or hazards.
The mult is applied to each discovery's layer base chance. The mushroom near-water proximity boost is the one exception: its flat 0.15 / 0.08 values are a designed absolute spawn rate, so _discovery_chance treats them as a floor — max(base × mult, boost) — rather than a base to re-multiply. Before this fix the mult tripled the boost (0.15 → 0.45) and, because water-pocket density also scales with the mult, ~94% of tiles ended up "near water" and mushrooms blanketed the mid layers (measured ~80% tile coverage at the cap; ~50% after the fix + the 2.5 ceiling).
Both the discovery mult and the chew stack compound, but only the discovery side is clamped. That is intentional: the reward plateaus, the cost does not.
Reset
reset() erases every astrolabe_* key from current_run (uses, chew mult, bounce dur/cd, exotics-seeded, discovery mult, and the two modal flags) so a brand-new run starts clean.
The Artificer's Harrow
The Harrow (scripts/core/HarrowManager.gd) is the 7th Challenge card: a design-your-own-challenge system unlocked once the Artificer specialist's work is mastered. A Harrow config is a dictionary:
{harrow: true, version, name, author, includes: [...challenge_ids], dials: {dial_id: t}}
where t in (0, 1] is severity (t = 0 means the dial is at baseline and omitted). The active config for a run lives in current_run["harrow"]. HarrowManager owns the catalog, the unlock gate, config hygiene, and the codes; the effect math lives in ChallengeManager (the multiplier owner) — the Harrow only supplies severity values.
The dial catalog
DIALS defines twelve dials across three clusters (CLUSTERS): "THE MAW AND THE WORLD", "YOUR WORKS FAIL YOU", and "THE VILLAGE IS FRAGILE". Each dial has a kind:
mult:dial_value()lerpsbase->worstbyt, multiplied on top of Challenge multipliers.abs: an absolute value consumed viamin()/lerpat its effect site (e.g.grace360 s -> 30 s,souls47 -> 1).composite: consumers readdial_t()and apply their own lerps (one named idea driving several knobs).
Ranges are harder-only by construction: t = 0 is always baseline. Representative dials include hunger (pressure growth x1 -> x4, and at full turn the cap rises too), learning (x1 -> x3), ritual_fury / "The Reckoning" (x1 -> x2.5), tools / "Green Iron" (x1 -> x0.4), walls / "Bad Mortar" (x1 -> x0.4), and souls / "The Few" (47 -> 1).
ritual_furymultiplies the ritual growth rate now that the stack compounds, so it is considerably sharper than its x2.5 label suggests across a multi-ritual run.ChallengeManager._CAP_RITUAL_CHEW(4.0) is the backstop on the combined Ravenous-Maw-times-dial figure.
The unlock gate
The Harrow is account-wide and unlocks when every built-in challenge has been mastered — i.e. you own all six challenge mastery trophies:
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 it keys off cosmetic ownership, the gate survives resets and restores. _maybe_announce_unseal fires a one-time toast (guarded by SaveManager.harrow_announced()) when the sixth trophy lands.
Config hygiene and codes
sanitize(cfg) is the single funnel for all external input (redemption codes, the design library, the UI). It rejects anything that is not a harrow payload, clamps every severity into (0, 1], drops sub-baseline or non-numeric dials, dedups and filters includes against ChallengeManager.ORDER, and caps the name at NAME_MAX_LEN (24). A hand-tampered code therefore cannot sneak below baseline and still post scores. Configs round-trip as UROOT1- codes (deflate + base64, same encoding as save codes) via export_code() / decode_code().
pending_config is staged by the Harrow screen's Begin button and read by GameManager.reset_for_new_run() into current_run["harrow"], then cleared — mirroring ChallengeManager.pending_selection.
Verification
tools/harrow_check.gd is a headless logic check (run as a temp autoload ZZHarrowCheck). It exercises sanitize (tampered payloads dropped, over-range clamped, sub-baseline dropped), code round-tripping, active-config queries, the ChallengeManager effect stacking (dial x 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/AstrolabeManager.gd |
Ritual activation, permanent chew stack, exotic seeding, discovery density, machine wipe. |
scripts/world/WorldManager.gd |
plan_connectivity_safe_fill() and _spare_protected_access() — the restore planner and its machine guarantees. |
scripts/village/SpecialistManager.gd |
Four specialists, arrival triggers, Artificer population gate, walk-in popups. |
scripts/core/HarrowManager.gd |
Harrow dial catalog, unlock gate, config sanitize/encode/decode. |
tools/astrolabe_curve_check.gd |
Headless chew-curve gate (CURVE OK / CURVE FAIL); prints the 12-ritual table. |
tools/harrow_check.gd |
Headless Harrow logic gate (HARROW OK / HARROW FAIL). |
data/balance.json (astrolabe, village) |
Consequence curve and artificer_population. |