Weather and Storms
Underroot's weather is four kinds — rain, drought, storms, and the aurora — and all of it is tunable from data now. Every number lives in data/balance.json under the weather section, read by WeatherManager.gd and WeatherSchedule.gd. This page lists each one with its exact default, what it does, and a safe range, so a balance pass can reach it without a developer.
This replaces the old guidance that "most storm tuning is not in the JSON." That was true of the first weather build; the reworked system moved everything into
balance.json. If you still have that model in your head, drop it.
How duration becomes magnitude
The system runs on one idea: duration is magnitude. When an event is scheduled it rolls a duration inside that kind's _duration_min/max, and where it lands (0..1) is its severity. Every consequence is that severity lerped between a _min and a _max value. A long drought is a severe drought; a short rain is a light one.
Storms are the exception: they are locked to a fixed 154 s to match their music, so they roll a severity randomly (uniform 0..1) instead of from duration. Everything else about them still lerps between min and max the same way.
Read the min/max pairs as "mild end" → "severe end," not "smallest number" → "largest number." For drought, several severe-end values are numerically lower (e.g. the well trickles at 0.6 in a mild drought, 0.4 in a severe one). The severity lerp always goes min → max regardless.
After any edit here, run the data validator (tools/validate_data.gd → DATA OK) and the smoke test. See The Safe Change Workflow.
Scheduling and cadence
One event is pending at a time, picked by weight = 1 / mean interval days. These govern how often weather comes and how it's spaced.
| Key | Default | Effect | Safe range |
|---|---|---|---|
time.day_duration |
360.0 s |
The base unit. All intervals below are counted in game days, so this scales every weather gap (and the whole game's pacing) at once. | 240–600 |
rain_interval_days_min / _max |
3.0 / 5.0 |
Mean gap between rains (the reciprocal is rain's pick weight). | 2–8 |
storm_interval_days_min / _max |
15.0 / 20.0 |
Mean gap between storms. | 10–30 |
drought_interval_days_min / _max |
8.0 / 14.0 |
Mean gap between droughts. _min is also the hard spacing floor. |
6–20 |
drought_first_day |
12.0 |
Earliest a drought can occur in a run — past the tutorial and the no-water-infrastructure window. Rain/storm have no _first_day, so they use their interval min. |
8–20 |
min_gap_days |
0.5 |
Fallback minimum clear days after any event, if a kind has no <kind>_min_gap_days. |
0.25–1 |
rain_min_gap_days |
0.5 |
Minimum clear days after rain. | 0.25–1 |
storm_min_gap_days |
2.0 |
Minimum clear days after a storm. | 1–4 |
drought_min_gap_days |
2.0 |
Minimum clear days after a drought. | 1–4 |
burst_window_days |
6.0 |
The window the burst brake watches. | 4–10 |
burst_max_events |
3 |
More than this many events inside the window forces a clear stretch. | 2–5 |
burst_clear_days |
2.0 |
Length of that forced calm. | 1–4 |
Overall weather occupancy sits near 30%; the gap and burst rules keep that from clumping into one punishing stretch.
Rain (harmless)
Rain refills water and helps digging. Fill is spread across the event, not dumped at the start.
| Key | Default | Effect | Safe range |
|---|---|---|---|
rain_duration_min / _max |
45.0 / 90.0 s |
Rain length; also sets severity. | 30–150 |
rain_fill_pct_min / _max |
0.08 / 0.22 |
Fraction of each water source's capacity a rain adds (pond always, well if built, tower if owned). | 0.05–0.35 |
rain_dig_speed_min / _max |
1.10 / 1.20 |
Dig-speed multiplier while raining (wet ground digs easier). | 1.0–1.4 |
rain_pocket_mult_min / _max |
1.5 / 2.0 |
Water-pocket spawn-roll multiplier — rain soaks the ground. | 1.0–3.0 |
Drought (a slow squeeze)
Drought tightens water, slows digging, and drains the pond to expose bed caches. Milder droughts sit at the _min end; severe ones reach _max.
| Key | Default (mild → severe) | Effect | Safe range |
|---|---|---|---|
drought_duration_min / _max |
360.0 → 900.0 s |
Drought length; also sets severity (longer = more severe). | 240–1200 |
drought_well_trickle_min / _max |
0.60 → 0.40 |
Well trickle multiplier — the well slows. | 0.2–0.9 |
drought_dig_speed_min / _max |
0.90 → 0.80 |
Dig-speed multiplier — the dry ground digs harder. | 0.7–1.0 |
drought_water_drain_min / _max |
1.09 → 1.18 |
Village + digger water-drain multiplier — the heat makes everyone thirstier. | 1.0–1.4 |
drought_pocket_mult_min / _max |
0.50 → 0.30 |
Water-pocket spawn-roll multiplier — the ground is parched. | 0.1–1.0 |
drought_caches_min / _max |
1 → 2 |
Pond-bed caches exposed as the water recedes (gold / root / exotics, worth a dive). | 0–4 |
drought_departure_reserve_days |
2.0 |
If village-accessible water (stored_water + well_water) falls under this many days of need, one villager leaves per day of drought. Runs alongside the normal metric-shortfall departures. |
1–4 |
Two drought numbers are script constants, not JSON — a developer edits them:
| Constant | File | Value | Effect |
|---|---|---|---|
DROUGHT_POND_EVAP_FRACTION |
WeatherManager.gd |
0.7 |
Fraction of pond capacity that evaporates across a full drought. The receding water is what exposes the caches. |
| Farmer's-call threshold | WeatherManager.gd |
pond < 60% |
When the farmer calls out the glinting bed (once per drought). |
Storms (deadly)
A storm runs warning → active → resolution. Timing is music-locked; every loss is severity-driven and JSON-tunable.
| Key | Default | Effect | Safe range |
|---|---|---|---|
storm_warning_duration |
58.0 s |
Warning phase before damage (matched to the storm music intro). Changing this desyncs the music — treat as fixed. | 58 (avoid) |
storm_duration |
96.0 s |
Active storm before losses resolve. Also music-matched. | 96 (avoid) |
storm_deaths_min / _max |
3 / 14 |
Villagers killed (severity-lerped, rounded). Clamped so population never drops below 1. | 0–25 |
storm_core_loss_min / _max |
0.04 / 0.18 |
Fraction of each core material destroyed (plus one random extra the player holds). | 0.02–0.30 |
storm_root_loss_min / _max |
0.25 / 0.55 |
Fraction of held strange root destroyed. | 0.1–0.7 |
storm_bind_strip_min / _max |
0.20 / 0.55 |
Fraction of root-bound wall columns that lose their binding. | 0.1–0.7 |
storm_wall_col_min / _max |
0.15 / 0.55 |
Fraction of each Maw front's wall columns torn out (contiguous, inward from the chew front). With Two Fronts both walls are hit, each from its own Maw. | 0.1–0.7 |
Storm structural constants (script, not JSON):
| Constant | File | Value | Effect |
|---|---|---|---|
STORM_CORE_MATS |
WeatherManager.gd |
["dirt","clay","stone","coal"] |
Materials a storm always hits. Excludes iron on purpose. |
STORM_CUTSCENE_DEFER |
WeatherManager.gd |
6.0 s |
Re-check delay when a cutscene owns the screen, so a storm never opens on top of one. |
When a storm resolves, all water sources fill completely — fixed behavior, not a tunable.
Aurora (a night boon)
The aurora is a reward, not a hazard: a night that gives more yield, slows the Maw, and brings souls to a fed village. It runs on its own season track, not the weighted scheduler.
| Key | Default | Effect | Safe range |
|---|---|---|---|
aurora_first_day_min / _max |
5.0 / 8.0 |
Day the first season opens — reachable, so players meet it early. | 3–12 |
aurora_second_gap_min / _max |
12.0 / 16.0 |
Gap before the second season (front-loaded so the post-first stretch isn't a letdown). | 8–20 |
aurora_gap_min / _max |
18.0 / 23.0 |
Gap before every later season. | 12–30 |
aurora_nights_min / _max |
2 / 3 |
Nights budgeted per open season. | 1–4 |
aurora_season_window_days |
6.0 |
Window the season's nights must fall within before it closes. | 4–10 |
aurora_night_fire_chance |
0.7 |
Per-clear-night chance the aurora actually lights (so nights aren't always consecutive). | 0.4–1.0 |
aurora_yield_base |
0.40 |
Base chance of a bonus dig unit on a lit night (before decay/strength). | 0.2–0.6 |
aurora_chew_relief_base |
0.35 |
Base Maw chew reduction on a lit night. Effective chew_mult is floored at 0.05, so the Maw never fully stops. |
0.2–0.5 |
aurora_soul_pct_base |
0.05 |
Base soul-arrival fraction on a lit night — only pays out to a fed village, and capped by the two keys below. | 0.02–0.10 |
aurora_soul_cap_min / _max |
14 / 19 |
Ceiling on the souls one lit night can draw. Rolled fresh each night inside this range, so the number stays unpredictable. | 8–30 |
aurora_astrolabe_strength |
1.3 |
Reward multiplier once the Astrolabe has been used (pre-ritual strength is 1.0). Applies to yield and chew relief — not to the soul cap. | 1.0–1.8 |
Aurora reward decays across a season's nights on a script constant AURORA_NIGHT_DECAY = [1.0, 0.7, 0.5] (WeatherSchedule.gd) — first night full, later nights less. The night window itself (day_time ≥ 0.78 or < 0.22) is also script-side. Aurora bonuses are paused during the offline catch-up sim, so they can't be farmed by closing the tab.
Why the souls are capped
aurora_soul_pct_base is a percentage of the village, but the ordinary daily population tick is flat-additive — its best tier gives at most +9 people per day at any village size. Uncapped, the aurora was the only proportional term in the system and ran away at scale: post-ritual it drew ~52 souls in one night at population 800, and a three-night season compounded to roughly +15% of the whole village.
The cap decays across a season on the same AURORA_NIGHT_DECAY vector as the rewards, so the season still fades instead of landing three identical nights:
| Night 1 | Night 2 | Night 3 | Season | |
|---|---|---|---|---|
| Ceiling | 14–19 | 10–13 | 7–10 | 31–41 |
It starts binding around population 215 (post-ritual). Below that the percentage is the smaller number and still rules, so early-game auroras are untouched — which is the point, since that is where the proportional design was doing its job.
The cap deliberately ignores aurora_astrolabe_strength. A post-ritual aurora burns brighter for yield and chew relief, but the souls are a hard top-out. If you want brighter auroras to draw bigger crowds too, that is a code change in WeatherSchedule.aurora_consequence(), not a JSON edit.
Tune it against the ~800 practical population ceiling, not against theoretical maximums — the surrounding systems are built for the 200–800 band. Raising the pair much past ~30 re-opens the runaway; dropping it under ~8 makes the aurora's soul beat vanish as a felt reward.
What you CAN tune from Challenges, not raw JSON
The Eye of the Storm challenge and the Harrow's "sky" dial multiply weather cadence through ChallengeManager.rain_interval_mult() / storm_interval_mult() (script-driven, not free-form JSON). These scale the intervals and the spacing floors together — floors only ever compress (clamped ≤ 1), so a stormy challenge stays relentless while a calmer one keeps the standard quiet rules.
Handle with care
day_durationis a global lever. It rescales the Maw's escalation clock, survival drain framing, villager daily ticks, and every "per day" cadence at once — not just weather. Don't reach for it to change how often it rains. See The Levers That Matter Most.- Don't move the storm timings.
storm_warning_durationandstorm_durationare matched to the storm track; changing them desyncs the music even though they're now JSON keys. - Mind the min/max direction on drought and pockets. Severity lerps min → max, and for those the severe end is the lower number. Edit the pair, then verify in-editor that a long event feels worse, not better.
- Any new proportional population source needs its own ceiling. The aurora is the cautionary tale: the daily tick it competes with is flat, so a percentage term will outgrow it at scale unless it is capped.
- Everything else here is a safe data edit: change the number, run the validator and smoke test, and check the feel in the editor.