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:
scripts/core/WeatherManager.gd— autoload. Owns the clock and thestate, applies results, and persists.scripts/core/WeatherSchedule.gd— pure static scheduling + consequence math. No node, no signals, no RNG of its own: every function takes itscfgdictionary and aRandomNumberGeneratorexplicitly, sotools/smoke_test.gdcan exercise it deterministically. It decides only what happens and how big;WeatherManagerowns the clock and applies it.
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:
- Earliest-due floor — a kind cannot fire before
<kind>_interval_days_min(× the challenge mult) since it last occurred. A kind that has never occurred this run uses<kind>_first_dayinstead, so drought's first onset waits until day 12 (past the tutorial, past the window where a new player has no water infrastructure). required_quiet()— minimum clear days after the most recent event: its<kind>_min_gap_days, plus a burst brake (more thanburst_max_eventsinsideburst_window_daysforcesburst_clear_daysof calm). This is a public function precisely so the smoke test can assert it directly, since the combined mean gap would otherwise mask it.
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:
- Evaporates the pond —
DROUGHT_POND_EVAP_FRACTION(0.7) of capacity drains across the full drought.drought_progress()(0..1) drives the amber vignette ramp. - Daily departure check — once per game day, if village-accessible water (
stored_water + well_water, not the digger's personal meter) is underdrought_departure_reserve_daysof need, one villager leaves viaVillageManager.drought_departure(). This runs in parallel with the normal metric-shortfall departures; the compounding is deliberate. - 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:
- A season opens on its own countdown (
aurora_season_gap: first at day 5–8, second gap 12–16, later 18–23 — front-loaded so the first is reachable and the post-first stretch isn't a letdown). - An open season budgets
aurora_nights_min..max(2–3) nights inside anaurora_season_window_days(6) window. Each night (day_time ≥ 0.78 or < 0.22), while the sky is otherwiseCLEARand no expedition is suspending weather, rolls oneaurora_night_fire_chance(0.7) — so lit nights are not necessarily consecutive. - A lit night's reward is
aurora_consequence(night_index, cfg, strength, rng)= base × decay × strength. Decay across a season is[1.0, 0.7, 0.5]; strength is 1.0 pre-ritual andaurora_astrolabe_strength(1.3) once the Astrolabe has been used (the sky answers the ritual).
The reward set: yield_bonus (probabilistic +1 dig unit via yield_bonus_chance → TerrainDigging.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_arrivals — souls 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:
- Villager deaths (
deaths, clamped so population never drops below 1) viaGameManager.record_villager_deaths(skips the per-villagervillager_diedpath). - Resource loss — every material in
STORM_CORE_MATS(["dirt","clay","stone","coal"]) losescore_lossof stock (≥ 1), plus one random extra the player holds. Iron is deliberately excluded. - Water fill — pond, well, and water tower topped out; a storm always leaves water abundant.
- Strange root —
root_lossof heldstrange_rootdestroyed, andbind_stripof bound wall columns lose their root binding. - Wall destruction — per front. A contiguous run of
wall_colof 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 (eastdir < 0walks −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.json → weather, 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. |