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:
_register_machine(machine)— writes eachmachine.position + (dx, dy)tile (over the machine's oriented footprint) into_occupied_tiles. Call after adding tomachines._unregister_machine(machine)— erases those same tiles. Call before removing frommachines.
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.
start_processing(id)checks the machine is idle with empty output, thatInventory.can_craft(recipe)holds, consumes the recipe inputs, setsprocess_durationfromDataRegistry.get_craft_duration, and activates it._tick_processor(machine, delta)advancesprocess_progress; upgraded machines tick atUPGRADE_TICK_MULT(1.334). On completion it depositsoutput_amountofoutput_idintooutput_inventory, incrementsuses_completed, and either breaks (onceuses_completed >= next_break_at), auto-restarts (auto_mode, consuming inputs again and settingprocess_duration = base_time * AUTO_PENALTY), or emitsmachine_idle.
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
scripts/machines/MachineManager.gd— machines dict,_occupied_tilesindex, placement, ticking, offline sim, repair/upgrade.scripts/machines/MachineData.gd— per-machine value object and its serialization.data/machines.json— machine definitions (footprint,is_processor,recipe_id, durability,max_placed, upgrade tables).