Machines

Placed machines are stored, kept spatially indexed, ticked each frame, and simulated offline. It all runs through the MachineManager autoload and the MachineData value object it manages.

The machines dictionary and spatial index

scripts/machines/MachineManager.gd holds two dictionaries that must stay in lockstep:

var machines: Dictionary = {}        # id -> MachineData
var _occupied_tiles: Dictionary = {} # Vector2i -> String (machine_id)

machines is the authoritative set of placed machines, keyed by MachineData.id. _occupied_tiles maps every tile a machine's footprint covers to that machine's id, so footprint checks are O(footprint) lookups instead of a nested loop over every placed machine.

A desync would corrupt placement checks, so the index is only mutated through two private helpers, called on every place, remove, relocate, and load:

reset() clears both together; load_from_save(data) clears both, rebuilds each MachineData via MachineData.from_dict, and re-registers it. occupied_tiles() returns a read-only view (used by the Astrolabe tile-restore so it never refills a tile a surviving machine sits on).

Footprint orientation

oriented_footprint(type, dir) returns the (w, h) footprint, swapping width and height for a directional machine (only steam_excavator, flagged directional in data/machines.json) aimed right or left. dig_face_offsets(machine) returns the cut-face tile offsets (length = dig_width) perpendicular to travel, and _dig_travel(machine) gives the advance delta once a face is clear (down by default; right/left for directional).

Footprint clearance checks

Three predicates read _occupied_tiles plus the terrain grid:

Method Passes when
is_footprint_clear(pos, w, h, exclude_id) Every tile is empty terrain and unoccupied by another machine. Used for underground placement.
is_footprint_solid(pos, w, h, exclude_id) Every tile is solid terrain and unoccupied — for machines that must sit on rock.
is_footprint_occupied(pos, w, h, exclude_id) Any tile is occupied by another machine — for surface machines where terrain at y=0 is solid by design.

exclude_id lets a machine ignore its own footprint (e.g. when relocating).

MachineData

scripts/machines/MachineData.gd is a class_name MachineData extends RefCounted value object. Beyond identity (id, machine_type, position, target_position, dig_direction) it carries per-machine runtime state used by the various tick paths:

Field group Fields
Fuel / activity fuel_material_id, fuel_remaining, fuel_quality, is_active, work_rate
Durability uses_completed, next_break_at (0 = not yet rolled), is_broken
Processor process_recipe, process_progress, process_duration, auto_mode, is_upgraded, pending_batches, output_inventory
Sawmill wood_progress, sawmill_upgrade, sawmill_wood_target
Hunting lodge lodge_timer, lodge_hunting, lodge_upgrade, lodge_food_target
Apothecary apoth_upgrade, apoth_stock, apoth_target, apoth_progress

create(type, pos) builds a fresh instance with id = "<type>_<x>_<y>". to_dict() / from_dict() serialize the full state; from_dict re-derives process_recipe from the machine def when the saved value is empty, and pulls work_rate from the def.

Placement

place_machine(type, tile_pos, dig_direction) is the general path (drills, excavators, pumps, lodge). It requires one of the machine item in Inventory, enforces the def's max_placed (counting only non-broken machines of that type), consumes the item, creates the MachineData, sets type-specific fields, adds it to machines, calls _register_machine, and emits EventBus.machine_placed. Type-specific setup: the pump gets its fuel_remaining from lifespan_days and claims the water it sits against_claim_water_pockets consumes every adjacent water discovery via DiscoveryManager.pump_discovery (no dig reward; the pump's well delivery IS the extraction, and a claimed pocket can't feed a second pump). The lodge starts its hunting timer.

place_static_machine(type, tile_pos) is the processor/structure path: it checks is_footprint_clear for the raw def footprint, sets process_recipe from the def's recipe_id, and registers the machine. Directional/miner target resets are handled by relocate_machine, which unregisters the old footprint, moves the machine, resets a miner's target_position, and re-registers — again keeping the index in sync.

Processing machines

Machines flagged is_processor in data/machines.json (smelter, forge, blast furnace, kiln, crucibles, etc.) convert inputs to outputs on a timer.

AUTO_PENALTY (1.334) and UPGRADE_TICK_MULT (1.334) are exact inverses, so an upgraded auto machine runs at manual speed. effective_process_seconds(recipe, auto, upgraded) mirrors this exactly for UI labels. collect_output(id) moves output_inventory into Inventory, clears pending_batches, and emits machine_idle to force a redraw.

The per-frame tick

process_machines(delta) (driven by the World.gd heartbeat) loops active machines and dispatches by kind: _tick_processor for recipe machines, _tick_lodge / _tick_apothecary / _tick_pump / _tick_sawmill for special buildings, and _tick_machine for fuel-burning miners (which damage tiles by work_rate * fuel_quality * delta, harvest via TerrainDigging.roll_yield, and _advance_target down the column). After the loop it removes worn-out fuel machines — except repairable surface buildings (lodge/sawmill/apothecary), which stay standing to be repaired — calling _unregister_machine before erasing each and emitting machine_broken.

Surface buildings regulate themselves. The sawmill, apothecary, and lodge "bank the fire" once their target (sawmill_wood_target, apoth_target, lodge_food_target) is met, burning nothing until stock drops. The lodge alternates hunting and resting phases and can, rarely, lose a villager (hunt_death_chance).

Durability, upgrades, and repair

_roll_break_at(def) rolls next_break_at between durability_min and durability_max (0 means no durability, e.g. the drill). upgrade_machine(id) spends one reinforced_frame to set is_upgraded. Broken machines are fixed by repair_machine(id) (spends repair_cost gold) or repair_machine_with_materials(id) (surface buildings use repair_materials; processors derive 2× their recipe inputs); both re-roll next_break_at. remove_machine(id) unregisters and erases.

Offline simulation

simulate_offline(elapsed_seconds, idle_mult) returns a {material_id -> amount} gains dict, catching each machine up over the away window on the idle clock: fuel burn and output both advance over elapsed * idle_mult seconds, and OfflineSimulator credits the gains 1:1, so offline fuel economics match live play. It skips processors, but handles the lodge (hunt/rest loop capped by fuel and the food target), the pump (credits well water against its lifespan clock — it has its own branch and never falls into the miner loop), the sawmill (banks the fire at its wood target), and generic miners (mining tiles against idle-scaled fuel time). The apothecary deliberately ignores idle_mult and brews on the full clock, consuming resources directly rather than through the gains dict — its doses pace the offline death sims, which also run full-rate. Food gains go back through SurvivalManager; pump water lands in the well; other materials are inventory items. See the offline sim page for how this fits the wider catch-up.

Key files