Idle and Offline Simulation
Underroot is an idle game. It keeps producing and consuming while a task runs in the background, and it catches the base up when the player returns after closing the tab. Two pieces handle that: TaskQueue, the queue of pending dig work, and OfflineSimulator, which replays the elapsed time across every subsystem on resume.
TaskQueue
scripts/idle/TaskQueue.gd is a thin FIFO over an Array of task dictionaries. A task has the shape { type, position, material_id, tool_id, progress, duration }.
| Method | Effect |
|---|---|
add_task(task) |
Append to queued_tasks. |
get_current_task() |
The head task, or {} if empty. |
complete_current_task() |
Pop the head. |
cancel_task_at(index) |
Remove a task by index. |
has_tasks() |
Whether the queue is non-empty. |
clear() |
Empty the queue. |
get_tasks_for_save() / load_from_save(data) |
Deep-copy round-trip for persistence. |
The queue holds no timing logic of its own; progress is advanced by whoever ticks it, live play or the offline sim. It's just the persistent record of dig work that survives a close.
OfflineSimulator
scripts/idle/OfflineSimulator.gd has one public entry point, simulate(elapsed_seconds). It builds a summary dictionary, runs each subsystem's catch-up in a deliberate order, emits EventBus.offline_return(summary), and returns it (the offline summary UI reads this).
It has two callers, not one:
| Caller | When |
|---|---|
| Game load | A previous timestamp exists — the ordinary resume path. |
BlackHollowManager.resolve_expedition() |
A Black Hollow expedition banks or busts, spending its days_away of village time. |
The second one has a sharp edge worth knowing about — see Called while the game is paused below.
Order of operations
The order matters — production must be credited before consumption so a net-positive base does not starve, and the digger must eat before the village rations. The whole run is bracketed by the weather's offline flag, because weather does not advance during a catch-up and its bonuses must not credit the catch-up's digs:
WeatherManager.begin_offline_sim()
_simulate_tasks(elapsed_seconds, summary)
_simulate_machines(elapsed_seconds, summary) # production first
_simulate_survival(elapsed_seconds, summary) # digger's own drain
_check_starvation(summary) # digger death check
_simulate_village_rationing(elapsed_seconds, summary)
_simulate_shelter_fuel(elapsed_seconds)
_simulate_black_rot(elapsed_seconds, summary)
_simulate_maw(elapsed_seconds, summary)
_check_warnings(summary)
WeatherManager.end_offline_sim()
Tasks
_simulate_tasks ticks TaskQueue down against the elapsed budget. For each task it completes within budget, it credits TerrainDigging.roll_yield(material_id) to both Inventory and summary.resources_gained; a partially-completed task keeps its progress for next time.
Machines
_simulate_machines calls MachineManager.simulate_offline(elapsed_seconds, SurvivalManager.get_idle_mult()). The machine sim advances on the idle clock: every fueled machine burns and produces over elapsed * idle_mult seconds, so the fuel cost per unit of output matches live play, and the returned gains are credited 1:1 — no second scaling. (The old scheme scaled only the gains while burning the full window's fuel, which emptied a full sawmill tank for ~15% of the wood.)
Routing: food (hunting lodge) fills the survival meter and overflows into the silo via SurvivalManager.add_supplies; pump water goes to the well via add_well_water, exactly like the live tick; everything else is added to Inventory.
The pump has its own branch in the machine sim — it credits water_output / lifespan per idle-clock second and burns down its lifespan clock; it never touches tiles. The apothecary is the deliberate exception to idle scaling: it brews on the full clock, because its doses pace the offline death sims (Black Rot, post-ritual losses), which also run full-rate.
Survival and the death check
_simulate_survival calls SurvivalManager.simulate_offline(elapsed_seconds), which drains only the digger's personal share (idle-scaled, cosmetic and challenge modifiers applied, well trickled at full rate) and returns {food_consumed, water_consumed}.
_check_starvation runs immediately after, between the digger's drain and the village's — so it sees exactly what the digger personally needed and had. It totals food/water (adding silo/tower storage and well_water where owned) and, on a zero, appends a warning, calls SurvivalManager.mark_dead(), and emits EventBus.player_died with a "while away" cause string. Because mark_dead() sets the dead flag without emitting, the simulator owns the emit and can construct the right cause.
Village rationing
_simulate_village_rationing draws the village's share of stored food/water after the digger (digger eats first), idle-scaled. When storage runs dry mid-window it computes dry_days and, if at least a day's worth, calls VillageManager.simulate_hunger_offline(dry_days, idle_mult) — the same attrition tiers the live daily tick applies to an unsupported village, on the idle clock — instead of killing the digger. The number lost and a warning go into the summary.
Shelter, Black Rot, and the Maw
_simulate_shelter_fuelcallsVillageManager.simulate_shelter_offline(days_passed, idle_mult)to burn fuel over the window._simulate_black_rotcallsVillageManager.simulate_black_rot_offline(days_passed). This is the challenge's defining drain — without it, closing the game would bypass the rot entirely. Losses are recorded viaGameManager.record_black_rot_deaths(raising graveyard crosses, matching the live tick) and reported in the summary. The rot is a discrete daily tax, not idle-scaled._simulate_mawcallsMawController.simulate_offline(...), stores the damage insummary.maw_damage, and then callsMawController.refresh_threat_state()so the summary headline and the live danger vignette reflect the worst front after catch-up, not a stale reading. The offline front loop only chews and advances — it never touches the light-surge timer, so no surge fires during a catch-up.
Warnings
_check_warnings scans MawController.fronts; if any front's threat_state is "critical" it appends "The Maw is at your gates." (covering either side under the Two Fronts challenge).
Called while the game is paused
simulate() is an ordinary synchronous function, so get_tree().paused does not hold it — and neither does the pause hold the signals it emits, or anything scheduled with call_deferred.
That matters on the Black Hollow path. resolve_expedition() runs the whole catch-up while the game is paused and the expedition's end screen is still up, so a full village tick — bells, population milestones, a possible death, toasts, narrative lines — lands during what the player experiences as the mini-game. Any listener that is PROCESS_MODE_ALWAYS reacts immediately; AudioManager is the obvious one, and it is why village music once started over the expedition track.
When you add a signal to any _simulate_* step, or an always-on listener anywhere, check it against this path. "The game is paused so it can't fire" is wrong here. See Black Hollow.
The summary dictionary
simulate returns a summary that the offline-return UI consumes. Its keys:
| Key | Contents |
|---|---|
elapsed_seconds, elapsed_display |
Raw and formatted away time. |
resources_gained |
{material_id -> amount} from tasks and machines. |
food_consumed, water_consumed |
Totals across digger and village. |
village_food, village_water |
The village's share specifically. |
maw_damage, walls_destroyed |
Maw catch-up results. |
villagers_lost_hunger, villagers_lost_black_rot |
Attrition counts. |
discoveries, warnings |
Discovery hits and player-facing warning strings. |
Key files
scripts/idle/TaskQueue.gd— FIFO of pending dig tasks; persistence round-trip.scripts/idle/OfflineSimulator.gd— resume catch-up across tasks, machines, survival, village, shelter, Black Rot, and the Maw.