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

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