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:

var max_placed: int = int(def0.get("max_placed", 0))
if max_placed > 0:
    ... # count active copies, reject if at cap
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 missing max_placed as "no cap"). For a fixed installation, either set an explicit max_placed, or rely on the controller path that supplies the default of 2. Miners like the drill set an explicit max_placed (3) and are placed through place_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.