Adding a Machine
A placeable machine — processor, miner, or fixed structure — is defined in data/machines.json and given a build recipe. scripts/machines/MachineManager.gd registers its footprint and enforces its placement cap.
The machine definition
One entry in data/machines.json, keyed by the machine type id. DataRegistry.get_machine_def(type) returns it ({} on miss). Two shapes — a fuel-burning miner and a recipe-driven processor:
"drill": {
"id": "drill",
"family": "drill",
"tier": 1,
"display_name": "Drill",
"description": "Drops onto a solid tile and burns fuel to dig downward...",
"work_rate": 5.0,
"max_fuel": 500.0,
"footprint_w": 1,
"footprint_h": 1,
"is_processor": false,
"durability_min": 50,
"durability_max": 50,
"max_placed": 3
},
"smelter": {
"id": "smelter",
"display_name": "Smelter",
"description": "Smelts raw iron ore into refined iron bars.",
"footprint_w": 3,
"footprint_h": 3,
"is_processor": true,
"recipe_id": "smelt_iron",
"work_rate": 0.0,
"max_fuel": 0.0,
"durability_min": 40,
"durability_max": 60,
"repair_cost": 25
}
Core fields:
| Field | Meaning |
|---|---|
id |
Must match the JSON key. |
display_name / description |
Player-facing text. |
footprint_w / footprint_h |
Tile footprint (drills are 1x1, processors 3x3). |
is_processor |
true = runs a recipe; consumes recipe_id inputs, emits output_id. |
recipe_id |
For processors: the recipe run each cycle (its category should be processing). |
work_rate |
For miners: tile-HP damage per second. |
max_fuel |
Fuel tank size in seconds; 0.0 for machines that carry no fuel. |
durability_min / durability_max |
Range for _roll_break_at(); both 0 means no durability (e.g. the drill's fixed life comes from its own count, structures have 0). |
repair_cost |
Gold cost to repair when broken (repair_machine). |
max_placed |
Cap on simultaneous placed copies. See the fallback below. |
Optional/specialised fields seen in the data: is_structure (Astrolabe, binding stake), is_pump + requires_well + lifespan_days + water_output (pump), is_lodge + hunt tuning (hunting lodge), is_explosive (dynamite, mining charge), dig_width + directional (excavators), process_section and forest_zone_max_x (surface buildings), selectable_recipes (glazing kiln), and per-machine upgrade arrays (sawmill_upgrades, lodge_upgrades, dose_levels).
The build recipe
A machine still needs a recipe to be craftable. Add it to data/recipes.json with category: "machines" (so CraftMenu renders it under MACHINES) and output_id set to the machine type:
"craft_apothecary": {
"display_name": "Build: Apothecary",
"category": "machines",
"input_materials": {"wood": 25, "stone": 15, "quartz": 4, "ancient_clay": 1},
"output_id": "apothecary",
"output_amount": 1,
"craft_time": 30.0,
"unlock_requires_astrolabe_uses": 2
}
The validator accepts a machine id as a recipe output_id, so output_id may point at your new machines.json entry.
Footprint registration
MachineManager keeps a spatial index, _occupied_tiles (Vector2i → machine_id), in lockstep with the machines dict so placement checks are O(footprint) instead of scanning every machine. Every place, remove, relocate, and load path must keep them in sync — this is done for you by the manager's helpers:
func _register_machine(machine: MachineData) -> void:
var fp := oriented_footprint(machine.machine_type, machine.dig_direction)
for dy in fp.y:
for dx in fp.x:
_occupied_tiles[machine.position + Vector2i(dx, dy)] = machine.id
Placement clearance uses is_footprint_clear() (terrain empty and unoccupied). Directional machines (only steam_excavator today) rotate their footprint via oriented_footprint(). If your machine is a standard processor/miner/structure, the existing place_machine() / place_static_machine() / relocate_machine() / load_from_save() paths register it correctly with no code changes.
Placement cap and the fallback
max_placed limits how many copies can exist at once. There are two enforcement layers, and they resolve the cap differently:
MachineManager.place_machine()reads the raw value and only enforces a cap when it is> 0:
var max_placed: int = int(def0.get("max_placed", 0))
if max_placed > 0:
... # count active copies, reject if at cap
- The controller and craft UI apply a default fallback so a fixed installation that omits
max_placedcan never read as unlimited.scenes/world/MachineController.gdresolves it:
func _placement_cap(def: Dictionary) -> int:
var mp: int = int(def.get("max_placed", 0))
return mp if mp > 0 else GameConstants.MACHINE_DEFAULT_MAX_PLACED
GameConstants.MACHINE_DEFAULT_MAX_PLACED is 2, and CraftMenu.MACHINE_MAX_OWNED resolves to the same constant so the craft gate and the placement guard agree.
The fallback is applied at the controller/UI layer for processors, structures, and surface buildings — not inside
MachineManager.place_machine()itself (which treats a missingmax_placedas "no cap"). For a fixed installation, either set an explicitmax_placed, or rely on the controller path that supplies the default of2. Miners like the drill set an explicitmax_placed(3) and are placed throughplace_machine().
Validate
After editing data/machines.json (and the recipe), run the validator. It checks the recipe's category and that its inputs/output_id resolve to real ids; the apothecary def has dedicated checks (fuel materials, dose tiers) if you touch it:
<godot console exe> --headless --path . --script res://tools/validate_data.gd
Expect DATA OK. Then run the smoke test to confirm machines survive a save round-trip and the offline sim (MachineManager.load_from_save rebuilds _occupied_tiles, and simulate_offline ticks fuel machines):
printf '\n[autoload]\n\nZZSmokeTest="*res://tools/smoke_test.gd"\n' >> project.godot
<godot console exe> --headless --path . # expect SMOKE PASS
git checkout -- project.godot
Placement feel, footprint visuals, and processing panels still need a visual check in the editor.
Key files
| File | Role |
|---|---|
data/machines.json |
Machine type definitions. |
scripts/machines/MachineManager.gd |
machines dict + _occupied_tiles; place/register/relocate/load/process. |
scripts/machines/MachineData.gd |
Per-instance runtime state (create, to_dict, from_dict). |
scenes/world/MachineController.gd |
Placement input, _placement_cap() fallback, panels. |
scripts/core/GameConstants.gd |
MACHINE_DEFAULT_MAX_PLACED = 2. |
tools/validate_data.gd |
Data validator. |