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.

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:

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.jsonmaw:

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 hungerpressure_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:

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:

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()

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.