The Maw
The Maw is what ends a run. One or more fronts grind toward the base at world X 0, eating whatever wall line stands in front of them; when a front's mouth reaches the base, it's over. MawController runs all of it. Three things are worth understanding before you touch any of it: how state splits between the controller and each front, how the pressure curve escalates, and why a legacy state dictionary shadows the whole thing.
Controller vs. front
State lives in two places. Keeping them straight saves a lot of grief.
MawController (the autoload) owns what's shared across the run: the clocks, the pressure curve, material familiarity, and the Astrolabe chew stack. One value each, applied to every front.
MawFront (scripts/maw/MawFront.gd, extends RefCounted) owns what's per-front: position, direction, stun, surge timers, column cooldowns, recent kills.
MawController.fronts is an Array[MawFront]. Element 0 is the east Maw — the original one — and it's mirrored into a legacy state dict so the pile of existing code that reads MawController.state.front_x keeps working.
var fronts: Array[MawFront] = []
Each front carries dir, the direction it advances. dir = -1.0 heads toward -x (the east front you already know); dir = +1.0 heads toward +x (the Two Fronts west front). Its mouth sits CHEW_OFFSET (75 px) village-ward of front_x:
func mouth() -> float:
return front_x - dir * CHEW_OFFSET
func chew_tile_x() -> int:
return int(floor(mouth() / float(GameConstants.TILE_SIZE)))
The legacy state mirror
Before the multi-front rework the Maw was a single dictionary. That dictionary is still here as MawController.state, kept in lockstep with fronts[0], so nothing that predates fronts has to change:
var state: Dictionary = {
"front_x": 620.0,
"base_chew_rate": 1.0,
"pressure_level": 1.0,
"growth_level": 1.0,
"total_consumed": 0.0,
"distance_to_base": 700.0,
"threat_state": "far"
}
_sync_state_from_front(fronts[0]) copies the front's front_x, distance_to_base, and threat_state into state after every tick and every save load. The shared keys (pressure_level, base_chew_rate, total_consumed, growth_level) belong to the controller; the sync leaves them alone.
stateonly ever mirrors the east front. In a Two Fronts run the east side can readfarwhile the west is a tick from breaking through, sostate.threat_stateis not the honest headline. When you need the truth across all fronts, callworst_threat_state()andmin_breach_eta_seconds().
The tick
World.gd calls process_chew(delta) every frame. It advances the run clock, updates pressure, then walks every front:
func process_chew(delta: float) -> void:
if _expedition_suspended:
return
_elapsed += delta
var hunger_delta := delta
if not DisplayServer.window_is_focused():
hunger_delta *= SurvivalManager.get_idle_mult()
_pressure_elapsed += hunger_delta
_update_pressure()
for f: MawFront in fronts:
_process_front(f, delta)
_sync_state_from_front(fronts[0])
Why there are two clocks
There are two run clocks because grace and hunger need to age at different rates.
| Field | Advances | Drives |
|---|---|---|
_elapsed |
Always at full rate (+= delta) |
Grace period, ramp, light-surge arming — the true run clock, exposed via get_elapsed() |
_pressure_elapsed |
Full rate when focused; throttled by SurvivalManager.get_idle_mult() when unfocused |
The pressure curve only |
Pressure is idle-throttled on purpose: long-term escalation should be an active-play thing, so backgrounding the window slows how fast the Maw grows hungrier — the same deal chew and survival drain already get. Both clocks save, so escalation picks up exactly where it left off.
Pressure escalation math
Pressure is a multiplier on the chew rate that climbs linearly with total (throttled) run time. A fortified wall line buys time; it doesn't remove the threat. The value is recomputed from _pressure_elapsed every tick, so save/load can't drift it:
func _update_pressure() -> void:
var days := _pressure_elapsed / TimeManager.DAY_DURATION
state.pressure_level = minf(
1.0 + PRESSURE_GROWTH_PER_DAY * ChallengeManager.pressure_growth_mult() * days,
PRESSURE_MAX + ChallengeManager.pressure_max_bonus())
_check_hunger_step()
So pressure_level = 1 + PRESSURE_GROWTH_PER_DAY * days, clamped at PRESSURE_MAX. Defaults come from data/balance.json → maw:
| Constant | Balance key | Default |
|---|---|---|
PRESSURE_GROWTH_PER_DAY |
pressure_growth_per_day |
0.04 |
PRESSURE_MAX |
pressure_max |
5.0 |
GRACE_PERIOD |
grace_period |
360.0 |
RAMP_DURATION |
ramp_duration |
90.0 |
FREE_ADVANCE_RATE |
free_advance_rate |
8.0 |
START_X |
start_x |
620.0 |
The two ChallengeManager hooks (pressure_growth_mult(), pressure_max_bonus()) are where challenges and the Artificer's Harrow steepen the curve and lift the ceiling. TimeManager.DAY_DURATION is 360 s.
Hunger steps
_check_hunger_step() fires a one-time toast whenever total hunger — pressure_level times the Astrolabe stack — crosses the next value in HUNGER_STEP_THRESHOLDS ([1.25, 1.5, 2.0, 2.5, 3.0, 4.0, 4.5]):
var total := float(state.pressure_level) * AstrolabeManager.get_chew_rate_mult()
The step it reached is stored in GameManager.current_run["maw_hunger_step"], so it survives save/load and never announces twice. Offline catch-up (_offline_sim) still advances the counter but stays quiet.
Grace, ramp, and the activity multiplier
_activity_multiplier() gates all movement: 0 during grace, a linear 0→1 lerp through the ramp, 1.0 after that.
func _activity_multiplier() -> float:
var grace := ChallengeManager.maw_grace_period(GRACE_PERIOD)
if _elapsed < grace:
return 0.0
if _elapsed < grace + RAMP_DURATION:
return (_elapsed - grace) / RAMP_DURATION
return 1.0
Inside _process_front, that base multiplier picks up everything else that speeds the Maw up:
mult *= AstrolabeManager.get_chew_rate_mult() # permanent post-ritual stack
mult *= ChallengeManager.maw_storm_mult() # Eye of the Storm — bolder under storms
if f.resonance_remaining > 0.0:
mult *= f.resonance_mult # light surge / resonance boost
An unfocused window folds SurvivalManager.get_idle_mult() in here too, matching survival drain.
Chewing a wall vs. free advance
Each front looks at the wall on its chew_tile_x(). Wall there? Its HP drops by the actual chew rate. Open tile? The front free-advances at FREE_ADVANCE_RATE * mult * delta. The chew rate itself folds in pressure, familiarity, and the material's resistance:
func get_actual_chew_rate(material_id: String) -> float:
var material := DataRegistry.get_material(material_id)
var resistance: float = material.get("base_chew_resistance", 1.0)
var familiarity_mult: float = MawAdaptation.get_chew_multiplier(material_id)
return state.base_chew_rate * state.pressure_level * familiarity_mult / maxf(resistance, 0.01)
Root-bound walls are the last line of defence. The binding fires while a sliver still stands (ROOT_FIRE_HP_PCT, 10% of max HP) rather than at 0, so the player actually sees the root throw the Maw back before the wall dies. consume_tile() spends the wall and its roots together, sets an escalating column cooldown (doubles per bounce, capped at 10 days, scaled by AstrolabeManager.get_bounce_cooldown_mult()), and calls repel(f).
Breach detection
A front breaches when its mouth crosses the base line from its own side. The test lives on the front:
func is_breached() -> bool:
return mouth() * dir > 0.0
_process_front checks this before applying stun, so a stun can never delay the game-over trigger. On breach it emits EventBus.maw_breached_base and returns. any_front_breached() is the run-ending condition that breach carry-on has to clear before it spawns a successor. breach_retreat() then shoves every front BREACH_RETREAT_X past its own wall line, stuns it, kills any live resonance, and scatters half-eaten debris columns down the corridor (_spawn_breach_debris) so the successor inherits chewable columns instead of a clear run to the base.
Threat state and breach ETA
_update_threat_state(f) sorts each front into far, near, or critical. Critical means a breach ETA inside 60 minutes, or surge exposure — one unbound column left, the kind of thing a single light-surge roll can erase:
if eta <= 3600.0 or is_surge_exposed(f):
f.threat_state = "critical"
elif f.distance_to_base > _threat_near_dist:
f.threat_state = "far"
else:
f.threat_state = "near"
HUD threat signals broadcast off the worst front (critical > near > far), so a receding west front can't cancel the red pulse while the east front is still critical. get_breach_eta_seconds(f) walks every tile from the front's mouth to X 0, summing chew time per standing wall (hp / get_actual_chew_rate) and travel time per open tile, adds whatever grace/ramp is left as a flat offset, then divides by the Astrolabe stack. Called with no argument, it uses fronts[0].
Light surges
Each front arms a light-surge timer after LIGHT_SURGE_MIN_DAYS (3 days), then fires on exponentially distributed intervals with a mean of LIGHT_SURGE_MEAN_DAYS (10 days):
func _sample_light_surge_interval() -> float:
return -log(maxf(randf(), 0.0001)) * LIGHT_SURGE_MEAN_DAYS * TimeManager.DAY_DURATION \
* ChallengeManager.surge_interval_mult()
A light surge is a one-tile lunge down the normal consume path, so a root-bound column absorbs it (repel fires, cooldown set) instead of getting skipped. If nothing absorbs it, the front gets a LIGHT_SURGE_BOOST_SECONDS (25 s) boost at LIGHT_SURGE_BOOST_MULT (1.5). A full resonance_surge(), driven from elsewhere, is the nastier cousin: it tears walls down outright, root or not.
Expedition suspension
suspend_for_expedition() / resume_from_expedition() hold the Maw while a Black Hollow expedition owns the screen, mirroring the pair WeatherManager exposes. The flag guards process_chew() and is cleared by reset(), so a fresh run can never inherit a stuck suspension.
The tree pause already freezes World's heartbeat, and with it this tick, so today the flag is a second line of defence rather than the first — it means a future non-pausing overlay can't quietly let the Maw chew behind the mine.
Two things it deliberately does not do:
- It does not cancel an in-flight surge. Weather reverts a storm because its cutscene would otherwise be stranded over the mine; a surge is just a speed boost, and clearing it would turn an expedition into a free escape from one. It freezes and resumes where it was.
- It does not touch
simulate_offline. The days an expedition costs are the price of the dive, and the Maw is meant to advance through them.
See Black Hollow.
MawAdaptation — per-material familiarity
MawAdaptation (scripts/maw/MawAdaptation.gd, autoload) tracks how much of each material the Maw has eaten and speeds up its chew rate against that material. Familiarity is shared across fronts — feed one front, and every front learns. It's credited per unit, so a 9-unit stack teaches nine times what a single mound does; mixed columns split the credit between their layer materials:
for layer: Dictionary in layers:
MawAdaptation.record_consumed(str(layer.get("id", material_id)), float(layer.get("units", 1)))
The multiplier caps at MAX_MULTIPLIER (3.0) and runs on one of two curves, keyed off the material's base_chew_resistance:
- Weak materials (
resistance < WEAK_THRESHOLD, 2.0) get a concavesqrt(x)curve: quick early gains that flatten aroundWEAK_SCALE(40) units. - Strong materials get a convex
x^2curve saturating atSTRONG_BASE_SCALE * resistance(30 × resistance): slow to start, and the tougher the material the more exposure it takes to cap.
if resistance < WEAK_THRESHOLD:
bonus = (MAX_MULTIPLIER - 1.0) * minf(1.0, sqrt(consumed / WEAK_SCALE))
else:
var scale := STRONG_BASE_SCALE * resistance
bonus = (MAX_MULTIPLIER - 1.0) * minf(1.0, pow(consumed / scale, 2.0))
data.chew_multiplier = 1.0 + bonus
record_consumed scales the credited amount by ChallengeManager.maw_learning_mult() (Ravenous Maw learns faster; its mastery trophy walks that back) and CosmeticManager.get_effect_mult("maw_learn_mult"). Crossing a familiarity level emits EventBus.maw_adapted; _mult_to_level() buckets the multiplier into levels 0–3 at 1.25 / 1.75 / 2.5.
Multi-front and Two Fronts
A fresh run has one front — _init_fronts() builds a single east Maw at START_X. The Two Fronts challenge appends a mirrored west front through spawn_west_front():
func spawn_west_front() -> void:
for f: MawFront in fronts:
if f.dir > 0.0:
return
var west := MawFront.new()
west.dir = 1.0
west.start_x = WEST_START_FRONT_X # -2850.0
west.front_x = WEST_START_FRONT_X
west.distance_to_base = absf(west.front_x)
fronts.append(west)
_devour_flora(west)
spawn_west_front() is idempotent, so a load-then-apply can't spawn two. The west front starts far out and grinds the whole way east, eating every tree, bush, and boulder it passes (_devour_flora, which only runs for dir > 0). Its wall line is GameConstants.WEST_WALL_LINE_X (-1500.0), not X 0; _wall_line_x(f) resolves that, and breach retreat and corridor debris measure from there so a west front never respawns village-side of its own wall.
front_for_tile(tile_x) answers which front owns a column: the east front (dir < 0) owns +x, a west front (dir > 0) owns -x. has_west_front() gates the west build zone and the B-key camera hop.
Save and load
The Maw's save payload is spread across a few top-level keys rather than one nested block, again for back-compat:
func append_save_data(out: Dictionary) -> void:
out["maw"] = state
out["maw_elapsed"] = _elapsed
out["maw_pressure_elapsed"] = _pressure_elapsed
out["maw_column_cooldowns"] = get_column_cooldowns_for_save()
out["maw_fronts"] = get_fronts_for_save()
- A
SAVE_VERSION3+ save carries the authoritative per-front data inmaw_fronts;load_fronts_from_save()rebuildsfrontsfrom it. - A legacy
≤ v2save has nomaw_fronts, sosync_front_zero_from_state()rebuildsfronts[0]from the mergedmawdict plus the separately-loaded column cooldowns. maw_pressure_elapsedback-fills frommaw_elapsedfor saves that predate the separate hunger clock, so an in-progress run keeps its hunger level instead of dropping.recent_killsis session-only by design (never saved); after a fresh load the breach-debris fallback covers the gap.
reset() (from GameManager.reset_for_new_run()) zeroes both clocks, clears _offline_sim and _expedition_suspended, resets the shared state keys, and calls _init_fronts().
Offline simulation
simulate_offline(elapsed_seconds) is the catch-up sim. It scales the effective chew budget by both SurvivalManager.get_idle_mult() (closing the game protects you the same as backgrounding it) and AstrolabeManager.get_chew_rate_mult(), advances both clocks, sets _offline_sim = true to mute toasts, and runs every front over its own copy of the budget in parallel.
_simulate_front_offline is a separate loop from the live tick: it only chews walls and advances, never touching light_surge_cooldown or _trigger_light_surge. No surge fires during a catch-up — live surges are additionally guarded by not _offline_sim. The wider resume flow is in Idle and Offline Simulation.
Key files
| File | Role |
|---|---|
scripts/maw/MawController.gd |
Autoload. Shared escalation, front processing, breach/threat/ETA, expedition suspension, save/load, offline sim. |
scripts/maw/MawFront.gd |
RefCounted per-front state: position, dir, stun, surge timers, column cooldowns, recent kills. |
scripts/maw/MawAdaptation.gd |
Autoload. Per-material familiarity curves and chew multiplier. |
data/balance.json (maw section) |
Grace, ramp, chew rate, pressure curve, surge, breach, and root tuning. |