# 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.

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

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

```gdscript
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.

> `state` only ever mirrors the east front. In a Two Fronts run the east side can read `far` while the west is a tick from breaking through, so `state.threat_state` is not the honest headline. When you need the truth across all fronts, call `worst_threat_state()` and `min_breach_eta_seconds()`.

## The tick

`World.gd` calls `process_chew(delta)` every frame. It advances the run clock, updates pressure, then walks every front:

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

```gdscript
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]`):

```gdscript
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.

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

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

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

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

```gdscript
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):

```gdscript
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](/docs/underroot/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:

```gdscript
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 concave `sqrt(x)` curve: quick early gains that flatten around `WEAK_SCALE` (40) units.
- **Strong** materials get a convex `x^2` curve saturating at `STRONG_BASE_SCALE * resistance` (30 × resistance): slow to start, and the tougher the material the more exposure it takes to cap.

```gdscript
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()`:

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

```gdscript
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_VERSION` 3+ save carries the authoritative per-front data in `maw_fronts`; `load_fronts_from_save()` rebuilds `fronts` from it.
- A legacy `≤ v2` save has no `maw_fronts`, so `sync_front_zero_from_state()` rebuilds `fronts[0]` from the merged `maw` dict plus the separately-loaded column cooldowns.
- `maw_pressure_elapsed` back-fills from `maw_elapsed` for saves that predate the separate hunger clock, so an in-progress run keeps its hunger level instead of dropping.
- `recent_kills` is 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](/docs/underroot/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. |

## Related

- [The Astrolabe](/docs/underroot/the-astrolabe-and-harrow)
- [Challenges](/docs/underroot/challenges)
- [Weather](/docs/underroot/weather)
- [Black Hollow](/docs/underroot/black-hollow)
- [Idle and Offline Simulation](/docs/underroot/idle-and-offline-simulation)
- [Survival and Village](/docs/underroot/survival-and-village)