Weather

Weather is a six-state machine that punctuates a run with rain, storms, droughts, and the aurora. One weighted event is pending at a time, picked by WeatherSchedule from balance.json's weather section; the aurora runs on a separate season track in parallel. The guiding rule is duration is magnitude — how long an event lasts places its severity in a 0..1 band, and every consequence is that band lerped across a min/max pair. Two files:

States

enum WeatherState { CLEAR, RAIN, STORM_WARNING, STORM, DROUGHT, AURORA }

RAIN, STORM, and DROUGHT are the weighted scheduler kinds (WeatherSchedule.KINDS). AURORA is not scheduled that way — it has its own season track (below). STORM_WARNING is the 58 s telegraph phase before a storm.

The single scheduler

The old parallel per-kind timers are gone. WeatherManager holds one pending event (_pending_kind, _pending_duration, _next_event_in) and re-arms it through WeatherSchedule.roll_next() after every event ends.

roll_next() does a weighted pick where weight = 1 / mean interval days, so one shared draw reproduces each kind's intended cadence. On top of that sit hard floors:

The delay is drawn at 0.6–1.4× the combined mean gap, then floored by both the picked kind's earliest-due and required_quiet(), so ~30% weather occupancy can never clump into one bad stretch.

Challenge/Harrow sky dials arrive as per-kind interval multipliers (ChallengeManager.rain_interval_mult() / storm_interval_mult()), passed into roll_next and folded into both the weights and the floors. Floors only ever scale down (clamped ≤ 1), so a challenge that makes weather rarer keeps the standard quiet rules while a stormy challenge can compress them.

_history (an array of {kind, ended_day}, trimmed to the burst window) and _last_by_kind feed these rules. They store absolute ended_day values on _elapsed's clock, so they must always be saved and restored together — splitting them skews since_last by the whole difference.

Duration is magnitude

severity_of(kind, duration, cfg) maps a rolled duration to 0..1 within that kind's <kind>_duration_min/max. consequence(kind, severity, cfg) then lerps every effect range by that severity and returns the full effect set; callers read only the keys they need.

Storms are the exception. They are music-locked to 154 s (58 s warning + 96 s storm), so they carry no duration range — FIXED_DURATION_KINDS = ["storm"] — and roll a severity independently with _rng.randf() at warning start. ScreenFlash reads storm_severity to telegraph how bad the incoming storm will be, and the warning toast picks one of three omen strings by severity band.

Rain

_begin_rain() resolves fill_pct, dig_speed, and pocket_mult from severity. Fill is delivered across the event by _drip(delta) rather than as a lump at the start, so the visible rain is the water actually arriving (the pond always, the well if SurvivalManager.well_built, the water tower if owned). Rain also speeds digging (dig_speed_mult) and soaks the water-pocket roll (water_pocket_mult > 1). Maren narrates the onset.

Drought

_begin_drought() resolves well_trickle, dig_speed (slower), water_drain (thirstier), caches, and pocket_mult (< 1, parched) from severity, and calls ForestManager.spawn_pond_caches(). While it runs, _process does three things per frame:

  1. Evaporates the pondDROUGHT_POND_EVAP_FRACTION (0.7) of capacity drains across the full drought. drought_progress() (0..1) drives the amber vignette ramp.
  2. Daily departure check — once per game day, if village-accessible water (stored_water + well_water, not the digger's personal meter) is under drought_departure_reserve_days of need, one villager leaves via VillageManager.drought_departure(). This runs in parallel with the normal metric-shortfall departures; the compounding is deliberate.
  3. Farmer's call — once the pond has visibly receded (< 60% of capacity) and caches exist, Maren calls out the glinting bed (toast + narrative). Not persisted, so a mid-drought reload re-fires it once.

_end_drought() clears caches (ForestManager.clear_pond_caches()) and restores every multiplier. The receding water is what exposes the caches; the slow refill afterward (pond regen ~0.048/s) is the recovery arc.

Aurora — the season track

Aurora is a night boon, not a weighted event, so it lives in _aurora_tick(delta), advanced every frame regardless of the main state:

The reward set: yield_bonus (probabilistic +1 dig unit via yield_bonus_chanceTerrainDigging.roll_yield), chew_mult (Maw recoils; floored at 0.05 so a strong aurora can't stop it outright), soul_pct (arrivals via VillageManager.aurora_arrivalssouls need a fed village), and soul_cap. The lit night ends at dawn (_end_aurora_night).

rng is optional: passed, the cap is rolled; omitted, it collapses to the range midpoint so the smoke test can read the consequence set deterministically.

Why soul_cap exists

soul_pct is proportional to population, but the ordinary daily tick in VillageManager._tick_population is flat-additive — its best tier is randi_range(1,3) + randi_range(4,6), so at most +9 people per day at any village size. The aurora was therefore the only proportional term in the system, and it ran away at scale: post-ritual it drew ~52 souls in one night at population 800, versus a fortnight of ordinary growth.

aurora_consequence now returns a soul_cap rolled per night from aurora_soul_cap_min/_max (14/19) and scaled by the same decay vector as the rewards — without that scaling every night of a season would clamp to the same number and the season's fade would disappear. aurora_arrivals(pct, cap) clamps to it, keeping the existing min_support >= population gate and the maxi(1, …) floor untouched.

Two deliberate calls: the cap ignores strength (a post-ritual aurora burns brighter but the souls hard-top-out), and it only starts binding around population 215, so early-game auroras are unchanged. Full tuning tables in Weather and Storms.

Offline and expedition holds

Aurora is offline-safe: OfflineSimulator brackets its catch-up with begin_offline_sim()/end_offline_sim(), and yield_bonus_chance() returns 0 while _offline_simulating, matching the offline chew path that ignores weather. Weather pauses offline.

It also honours the expedition hold. _begin_pending() refuses to open rain/storm/drought while _suspended, but the aurora runs on its own track and so needs saying separately — the night's fire roll carries its own not _suspended check. The check is on the roll, not the whole tick, so a suspended night is deferred rather than spent: _aurora_night_rolled stays false and the night comes back when the player does. See Black Hollow.

Storm resolution

_resolve_storm()_apply_storm_effects() reads the storm consequence set from severity and applies five things, returning a result dictionary carried on EventBus.storm_ended:

  1. Villager deaths (deaths, clamped so population never drops below 1) via GameManager.record_villager_deaths (skips the per-villager villager_died path).
  2. Resource loss — every material in STORM_CORE_MATS (["dirt","clay","stone","coal"]) loses core_loss of stock (≥ 1), plus one random extra the player holds. Iron is deliberately excluded.
  3. Water fill — pond, well, and water tower topped out; a storm always leaves water abundant.
  4. Strange rootroot_loss of held strange_root destroyed, and bind_strip of bound wall columns lose their root binding.
  5. Wall destruction — per front. A contiguous run of wall_col of each front's wall columns is ripped out, measured inward from that front's chew position. _storm_batter_front(f, col_fraction) walks the front's direction (east dir < 0 walks −x, a Two Fronts west front walks +x), stopping at the first gap or a column the Maw hasn't reached. Both walls take a battering, each from its own Maw. See The Maw.

Cutscene deferral

A storm coming due while a full-screen cutscene plays (Astrolabe ritual, population milestone) is pushed out by STORM_CUTSCENE_DEFER (6 s) and re-checked, so it never opens on top of another cutscene. _cutscene_depth is kept in sync via EventBus.cutscene_started/cutscene_ended; reset() clears it. Rain and drought have no cutscene of their own and never defer.

Expedition suspension

suspend_for_expedition() / resume_from_expedition() hold the weather while a Black Hollow expedition owns the screen. Suspending reverts an in-progress STORM_WARNING/STORM to CLEAR, re-arms the scheduler, and emits EventBus.storm_toast_confirmed — which is the teardown signal the cutscene, ScreenFlash, StormToast, the debris canvas, and the storm music all listen for, so nothing is left stranded over the mine. It is idempotent: both the route menu and the dig overlay call it, and only the overlay resumes.

The tree pause already freezes this whole scheduler, so _suspended is a second line of defence rather than the first. MawController carries a matching pair for the same reason.

What other systems read

Accessor Read by Effect
dig_speed_mult() TerrainDigging.get_dig_time Rain faster, drought slower
water_drain_mult() SurvivalManager (live only) Drought = thirstier village
well_trickle_mult() SurvivalManager Drought slows the well
pond_regen_mult() ForestManager._process 0 during drought
water_pocket_mult() DiscoveryManager Rain soaks (×1.5–2.0), drought parches (×0.5–0.3)
yield_bonus_chance() TerrainDigging.roll_yield Aurora +1 dig-unit chance (0 offline)
maw_chew_mult() MawController Aurora slows the Maw
drought_progress() amber vignette 0..1 through the drought

Persistence

Weather rides a top-level weather save section, matching survival/village/maw. Absent on pre-existing saves, which load as a fresh CLEAR schedule — no SAVE_VERSION bump needed. A save written mid-STORM_WARNING/STORM restores as CLEAR (no music/cutscene to resume) and re-arms the scheduler; restored rain/drought/aurora re-emit their *_started signal deferred so listeners connected this frame still catch it. load_save_data filters _history/_last_by_kind rather than trusting them, since WeatherSchedule iterates history with typed loops.

Tuning

Every weather number lives in data/balance.jsonweather, read via _cfg(). See Weather and Storms for the full value tables and safe ranges. Run the data validator after any balance.json edit — and when a script starts reading a new balance key, add it to REQUIRED_BALANCE_KEYS in tools/validate_data.gd.

Key files

File Role
scripts/core/WeatherManager.gd Autoload. Clock, state, application, persistence.
scripts/core/WeatherSchedule.gd Pure static scheduling + consequence math.
data/balance.json (weather, time) All tuning; time.day_duration is the scheduling base unit.