First Machine

A machine is more than a block. It fills more than its own cell of the world, it keeps state that has to survive a save and a reload, it takes power from an axle, and a player clicking one corner of it expects something different from a click on the other side. Vintage Story gives you a block per cell and a block entity - the object the game keeps beside a placed block to hold that block's state and run its ticks - and nothing above that. Everything else is yours to write.

exlib's answer is the mega-block: one real block, called the principal, with invisible filler blocks standing in the rest of its volume so the machine has real collision everywhere it looks solid, and with every click on a filler routed back to the principal. Multiblock Structures is the reference page for that system; this page is the walkthrough.

It builds two machines, in order, from the samples in the exlib repository. Getting Started has already walked the first, samples/TwinTubBlower, as a single block with a config value and a test. The first section here gives that block its footprint and the drive port that turns it. The rest builds a second machine from nothing, samples/BurdenMaker: a nine-cell stock house the player raises in five stages of right-click construction, with two hoppers, a shared basin, and a click that means something different on every cell.

Every snippet is copied verbatim from those samples, so you can open the file beside the section that quotes it. Read Getting Started first if you have not - this page assumes the code-first block definitions and the attribute registration it covers.

The blower's footprint: filler cells and a hosted mechanical-power port

The twin-tub blower is a bellows that pumps into the gas-pipe network it stands in, turned from outside by an axle. In the world it is one real block - the principal, which is also the pipe node - and five invisible filler cells that give the rest of its 1x2x3 volume real collision, so a player cannot walk through the part of the machine that has no block of its own.

A filler cell can do more than stand there. One of these, the upper-rear one, hosts a mechanical-power port. Mechanical power is the game's network of axles and gears, the one a windmill or a water wheel drives; a port is where a machine couples to it. Hosting the port on a filler is what lets an axle meet the machine at the height the model shows it, instead of at the principal's own cell:

private static readonly FillerBehaviorSpec MpPortWest =
  FillerBehaviorSpec.Of<BEBehaviorMPFillerPort>(
    "west",
    new { through = false }
  );

private static readonly FillerBehaviorSpec PipeThrough =
  FillerBehaviorSpec.Of<BEBehaviorNetworkMember>(
    "north",
    new { networkType = "pipe", passThrough = true }
  );

.FillerOffsets(
  StructureFootprint.Layout(f =>
    f.Host('M', MpPortWest)
      .Host('p', PipeThrough)
      .Slab('_', BlockFacing.DOWN)
      .Origin(-2, 1)
      .Slice(
        0,
        """
        _ # M
        p p 0
        """
      )
  )
)

The footprint is drawn as text, one character per cell, and the legend above it says what each character is. Host is the part that separates this from a plain filler: the cell carries a block entity behaviour of its own - a component the game attaches to a block entity to add one piece of behaviour without a subclass - configured by the face it faces and a small property bag.

M hosts the mechanical-power port, so an axle mounted on that cell's west face turns the bellows. p hosts a pipe membership marked passThrough, which keeps a pipe run coupled two cells out through the blower's own -Z face connected to the network instead of dead-ending at the first filler it touches. _ is a slab rather than a full cube, so the blower does not block the floor tile in front of it. 0 is the principal, the one real block, and the cell every other cell is drawn relative to.

Slice draws one vertical grid at a fixed X: rows run downwards from the top, columns run +Z. Its siblings Layer (a floor plan at a fixed Y) and Face (an elevation at a fixed Z) read the same way, and a layout may mix all three - see Multiblock Structures "One grid, three uses".

Most blocks with a footprint derive from BlockFilledMegastructure, the base every other filler footprint in this wiki subclasses, and it places and removes the fillers for them. The blower cannot. That base is a plain Block, and the blower has to be a BlockPipe to be a node of the gas network; a class gets one base. So it implements IFillerHost directly and drives the three placement hooks itself:

public override bool CanPlaceBlock(
  IWorldAccessor world,
  IPlayer byPlayer,
  BlockSelection blockSel,
  ref string failureCode
) {
  if (!base.CanPlaceBlock(world, byPlayer, blockSel, ref failureCode))
    return false;
  if (!StructureFillers.CanPlace(world, FootprintCells(blockSel.Position))) {
    failureCode = "notenoughspace";
    return false;
  }
  return true;
}

public override void OnBlockPlaced(
  IWorldAccessor world,
  BlockPos blockPos,
  ItemStack? byItemStack = null
) {
  base.OnBlockPlaced(world, blockPos, byItemStack);
  StructureFillers.PlaceFillers(world, blockPos, FootprintCells(blockPos));
}

public override void OnBlockRemoved(IWorldAccessor world, BlockPos pos) {
  // Runs on every removal path (a player break, an explosion, a worldedit delete), unlike
  // OnBlockBroken, so the reserved volume is never left behind.
  StructureFillers.RemoveFillers(world, pos, FootprintCells(pos));
  base.OnBlockRemoved(world, pos);
}

CanPlaceBlock is the question the game asks before the block goes down; answering false with a failure code is what tells the player there is no room, rather than swallowing the click; the code is a key the game turns into the message on screen. OnBlockPlaced puts the fillers in once the principal exists, and OnBlockRemoved takes them out again - the comment in it is the reason to override that one rather than OnBlockBroken.

FootprintCells rotates the authored layout by StructureAngle, which the blower reads off its own orientation variant. A variant is one axis of a blocktype that the game expands into a separate block per value, so a blower facing east is its own block and knows its facing from its code. The layout is authored once, facing north, and rotated on the way out, so the three calls above are the whole placement and removal story at every facing.

On the block-entity side, the port cell is found the same way - a rotated offset from the principal, looked up for its behaviour, rather than stored as a coupling of its own:

private static readonly Vec3i MpPortCell = new(0, 1, 0);

private BEBehaviorMPFillerPort? Port() {
  BlockPos cell = ExOrientation.GlobalPos(
    Pos,
    MpPortCell.X,
    MpPortCell.Y,
    MpPortCell.Z,
    Angle
  );
  return Api
    .World.BlockAccessor.GetBlockEntity(cell)
    ?.GetBehavior<BEBehaviorMPFillerPort>();
}

private float PortSpeed() =>
  Port() is { IsTurning: true } port ? port.Speed : 0f;

PortSpeed asks the port how fast it is turning and gets 0 when nothing drives it. That number is the machine's whole power input: the bellows animate, sound and push air into the pipe network in proportion to it. through = false, back in the spec, makes the port one-sided. The cell couples an axle on that face alone and the network ends there, where the default lets a row of ports carry power straight through a machine and out the other side.

An animated part driven by the axle should read the port's DrivenAngleRad, which is the rotation in the sense the axle is drawn on whichever side the port faces. CurrentAngleRad is the network's own angle and reads mirrored on a west or north port, so a gear keyed to it turns backwards on half the facings.

TwinTubBlowerTests confirms the shipped footprint hosts that port on the cell the code above expects, and that a pipe coupled two cells out through the pass-through cells still joins the blower's own network - see Multiblock Structures for the fuller FillerBehaviorSpec and Host surface.

The burden maker: a designed multiblock stock house

samples/BurdenMaker is a nine-cell mega-block: two hoppers, one for ore and one for flux, over a shared basin with a sliding gate between them. A burden is the measured mix of ore and flux a furnace is charged with, and this machine is where a player makes one. Load both hoppers, open the gate, and what reaches the basin is one batch stamped with the proportions it was made at.

Nothing here turns and nothing needs power. The machine is a footprint, a right-click construction in five stages, and a container block entity, which makes it the one to read for everything about a multiblock except the power line above.

# # #
# O #
# # #
. . .

# marks a filler cell, O the principal (the gate), . an open cell. The first grid is the lower layer, the basin, with the gate among its cells. The second is the layer above it: the two hoppers fill the row behind the gate, and the three cells directly over the gate are left open so a player can reach into the basin.

One ExBlockDef declares all of it. Read it from the top: what the block is and which two classes run it, how it breaks and what it drops, the footprint drawn above, the behaviours it carries, the five stages it is built in, and last the variants it expands into with the shape and textures each one draws.

[BlockRegister]
public partial class BlockBurdenmaker
  : BlockFilledMegastructure,
    IFillerHost,
    IFillerInteractionTarget,
    IExBlockDefProvider {
  public static IEnumerable<ExBlockDef> Definitions(string domain) =>
    [
      ExBlockDef
        .Create(domain, "burdenmaker", "ore/burdenmaker")
        .Class<BlockBurdenmaker>()
        .EntityClass<BlockEntityBurdenmaker>()
        .Material(EnumBlockMaterial.Ceramic)
        .MiningTier(0)
        .Resistance(3.5f)
        .MaxStackSize(1)
        .NoDrops()
        .FillerOffsets(
          StructureFootprint.Layout(f =>
            f.Origin(-1, -1)
              .Layer(
                0,
                """
                # # #
                # O #
                """
              )
              .Layer(
                1,
                """
                # # #
                . . .
                """
              )
          )
        )
        .Behavior("ExOrientable")
        .Behavior("BlockEntityInteract")
        .EntityBehavior("Animatable")
        .Construction(c =>
          c.Stage(s => s.AddElements("Root/Base"))
            .Stage(s =>
              s.Require("game:burnedbrick-{brick}", 12, "burdenmaker:rcc-ingredient-brick")
                .AddElements("Root/BaseExtension")
            )
            .Stage(s =>
              s.Require("game:burnedbrick-{brick}", 16, "burdenmaker:rcc-ingredient-brick")
                .AddElements("Root/HopperMasonry")
            )
            .Stage(s =>
              s.Require("game:metalplate-iron", 6, "burdenmaker:rcc-ingredient-hopperplate")
                .AddElements("Root/Hoppers")
            )
            .Stage(s =>
              s.Require("game:metalplate-iron", 3, "burdenmaker:rcc-ingredient-lidplate")
                .Require("game:ingot-iron", 2, "burdenmaker:rcc-ingredient-lidrails")
                .AddElements("Root/Lids")
            )
        )
        .VariantGroup("brick", "black", "brown", "cream", "gray", "orange", "red", "tan")
        .SideVariant()
        .CreativeTab("general", "*-cream-n")
        .CreativeTab("burdenmaker", "*-cream-n")
        .ShapeSpunPerOrientation("burdenmaker:ore/burdenmaker")
        .ShapeSelectiveElements("Root/Base/*")
        .Texture("fire1", "game:block/clay/brick/four/running/cream1", "game:block/clay/brick/four/running/{brick}1")
        .SingleSelectionBox(0f, 0f, 0f, 1f, 1f, 1f)
        .SingleCollisionBox(0f, 0f, 0f, 1f, 1f, 1f)
        .SideSolid(false)
        .SideOpaque(false),
    ];

  public override int StructureAngle =>
    ExOrientation.AngleFromSide(Variant["side"]);
}

Construction lays out the five stages the player walks through after placing the block. Right-click construction is the game's way of raising a block in stages instead of placing it whole: the player holds the next material, right-clicks, and each satisfied stage reveals another part of the model. Here the stages are the bare base, the base extension, the hopper masonry, the hopper ironwork and the gate lids, and each Stage names both the materials it consumes and the group of shape elements it reveals (Root/BaseExtension, Root/HopperMasonry, and so on).

ShapeSelectiveElements is what the placed shell shows before any stage completes, the bare basin floor. NoDrops, together with the GetDrops override in the source's "Drops" region, is what stops the machine dropping itself as an item when broken: what the player gets back is the construction materials and whatever the hoppers and basin held, never the block. Construction (RCC) covers the stages, the salvage fraction and the animator a constructable block needs to be visible at all.

The brick variant group is why a burdenmaker comes in seven brick colours rather than one. A variant group expands the definition into one block per value and substitutes that value wherever the declaration writes {brick}, which here is the texture path alone: a burdenmaker built from red brick differs from one built from cream brick by that one texture reference and nothing else. See Code-First Definitions "Textures" for Texture(string, string, string), the two-pattern overload used here.

Cell classification: the cell is the verb

The machine opens no dialog. Most containers in the game answer a right-click with an inventory window; this one answers with whatever the cell under the crosshair means, so a click on a hopper loads it and a click on the basin takes from it. Which cell was clicked is read back from its offset to the principal, rotated into the frame the layout was authored in:

public enum BurdenmakerCell {
  OreHopper,
  FluxHopper,
  Gate,
  Bunker,
  Outside,
}

public static BurdenmakerCell Classify(
  BlockPos principal,
  BlockPos clicked,
  int structureAngle
) {
  Vec3i local = ExOrientation.RotateOffset(
    clicked.X - principal.X,
    clicked.Y - principal.Y,
    clicked.Z - principal.Z,
    -structureAngle
  );

  return (local.X, local.Y, local.Z) switch {
    ( >= -1 and <= 0, 1, -1) => BurdenmakerCell.OreHopper,
    (1, 1, -1) => BurdenmakerCell.FluxHopper,
    (0, 0, 0) => BurdenmakerCell.Gate,
    ( >= -1 and <= 1, 0, -1 or 0) => BurdenmakerCell.Bunker,
    _ => BurdenmakerCell.Outside,
  };
}

The inverse rotation (-structureAngle) is what makes the table above one table rather than four. The two hoppers differ by X, so comparing raw world coordinates would read correctly on north and put ore in the flux hopper on the facings where X and Z have swapped. Rotating the offset back into the authored frame first means the cases are written once, for the facing you drew.

Classify takes three positions and an angle and touches nothing else - no world, no block entity - so BurdenmakerCellTests checks the whole per-facing table with no world at all.

A click on the principal, the gate, arrives through OnBlockInteractStart, the hook the game calls on a block the player right-clicks. A click on any other cell lands on a filler, which is not your block at all, so it arrives through IFillerInteractionTarget: BlockStructureFiller forwards it to the principal and carries the clicked cell's own position along with it.

public override bool OnBlockInteractStart(
  IWorldAccessor world,
  IPlayer byPlayer,
  BlockSelection blockSel
) =>
  HandleInteract(world, byPlayer, blockSel, blockSel.Position)
  ?? base.OnBlockInteractStart(world, byPlayer, blockSel);

bool IFillerInteractionTarget.OnFillerInteractStart(
  IWorldAccessor world,
  IPlayer byPlayer,
  BlockSelection principalSel,
  BlockPos clickedCell
) =>
  HandleInteract(world, byPlayer, principalSel, clickedCell)
  ?? base.OnBlockInteractStart(world, byPlayer, principalSel);

Both paths meet in HandleInteract, and it returns null before construction completes so that an early click falls through to the construction behaviour rather than being swallowed by a machine that is not built yet. Once the machine is finished it switches on Classify's answer: the gate toggles, a hopper loads from the active hotbar slot - the one the player is holding - or gives back when that slot is empty, and the basin only ever gives back. Ctrl moves the whole stack and a plain click moves one item; sneak is left alone, since the game's own ground-storage placement already claims sneak plus right-click.

switch (cell) {
  case BurdenmakerCell.Gate:
    if (!be.ToggleGate(out string? error) && error != null)
      (byPlayer as IServerPlayer)?.SendIngameError(error);
    break;

  case BurdenmakerCell.OreHopper:
    if (active?.Empty == false)
      be.TryLoadOre(active, wholeStack);
    else
      GiveBack(world, byPlayer, sel, be.TryTakeOre());
    break;

  case BurdenmakerCell.Bunker:
    GiveBack(world, byPlayer, sel, be.TryWithdrawBurden());
    break;
}

(The flux hopper's case, symmetric with ore, is omitted here.) Interaction help is the hint the game prints while a player looks at a block, and it has to match what the click will do. GetPlacedBlockInteractionHelp and IFillerInteractionTarget.GetFillerInteractionHelp route through the same Classify call as the click itself, so "add one ore" or "take" appears on whichever cell the player is looking at and cannot drift from the behaviour.

The container: per-cell acceptance, not a fixed list

A block entity that holds items owns an inventory: a flat array of slots the game saves, syncs and lets hoppers and chutes reach into. BlockEntityBurdenmaker divides one inventory into three fixed slot ranges, ore, flux and basin, so which tank a slot belongs to is a property of its index rather than of what happens to be sitting in it.

What each range accepts is the interesting part. CanContain is the question the engine asks before any stack moves into a slot, from a player or from a machine, and answering it with a hand-written list of item codes would mean no other mod could ever feed this machine. Asking MaterialRoleRegistry instead - the catalogue of which items count as ore, flux, fuel or scrap - means an ore contributed by another mod's materialroles.json is accepted exactly as the shipped one is. Extending Processes is how that catalogue is contributed to.

public static bool IsOre(ItemStack? stack) =>
  stack?.Collectible?.Code is { } code
  && MaterialRoleRegistry.IsRole(Roles.IronOre, code);

public static bool IsFlux(ItemStack? stack) =>
  stack?.Collectible?.Code is { } code
  && MaterialRoleRegistry.IsRole(Roles.Flux, code);

private class InventoryBurdenmaker(int size) : InventoryGeneric(size, null, null) {
  public override bool CanContain(ItemSlot sink, ItemSlot from) {
    int index = GetSlotId(sink);
    ItemStack? stack = from?.Itemstack;
    return index switch {
      >= OreFirst and < FluxFirst => IsOre(stack),
      >= FluxFirst and < BunkerFirst => IsFlux(stack),
      // The basin is filled by the gate, never by hand or by a chute.
      _ => false,
    };
  }
}

Opening the gate is the one step that is neither a load nor a take. It fixes the batch: the ore and flux the hoppers hold at that moment become the mix every slice of this batch is stamped with, and a server tick then moves the hoppers' contents down into the basin over BurdenmakerDrainSeconds, so the player watches the burden go down rather than seeing it jump. The gate refuses to open with nothing loaded, and refuses to pour onto a finished batch still in the basin, since two batches averaged into one stack would carry the wrong stamp; a batch that is still draining may be closed and reopened. Both refusals come back as an error code rather than a bare false, which is what lets the caller put a message on the player's screen:

public bool ToggleGate(out string? errorCode) {
  errorCode = null;

  if (_gateOpen) {
    _gateOpen = false;
    StopDrain();
    ApplyPose();
    MarkDirty(true);
    return true;
  }

  int ore = OreUnits;
  int flux = FluxUnits;
  if (ore + flux == 0) {
    errorCode = "burdenmaker-nothingloaded";
    return false;
  }
  if (BurdenUnits > 0 && !_batchInProgress) {
    errorCode = "burdenmaker-emptybunker";
    return false;
  }

  if (!_batchInProgress) {
    float total = ore + flux;
    _batchMix = new BurdenMix(ore / total, flux / total);
    _batchTotal = ore + flux;
    _batchInProgress = true;
  }

  _gateOpen = true;
  ApplyPose();
  StartDrain();
  MarkDirty(true);
  return true;
}

DrainTick runs every 250 ms on the server, takes one slice from the hoppers in the batch's own ore-to-flux proportion, and merges it into the basin's stack, which carries the batch's ore and flux fractions as attributes (Burden.Write) wherever it goes. The tooltip reads them back, and so does the "would make burden at N% flux" preview GetBlockInfo prints while a player looks at the machine. Both grade that number through the same flux bands, BurdenMakerValues.BurdenProfiles, which come from the mod's config, so a server can retune what counts as under-, standard- or over-fluxed without a recompile. ApplyPose, the drain helpers and the load and take helpers are omitted here; a constructable block draws no mesh of its own once built, so one animation clip must always be running for it to be visible at all, and ApplyPose is what keeps the right one running.

The tests

A machine like this is worth testing at three levels, and the sample has one test class for each.

BurdenmakerCellTests checks the whole Classify table above at all four facings with no world at all - a pure function over three BlockPoses and an angle needs nothing else, which is the payoff for keeping the classification static. BurdenmakerInteractionTests and BurdenmakerTests use Testing Harness's TestWorld to drive loading, taking and the gate through a real block entity, with no game running. MaterialRoleSeedTests (under tests/Invariants) checks that MaterialRoleSeeds - the headless stand-in for assets/burdenmaker/config/materialroles.json, which the harness has no asset pipeline to load - mirrors that file entry for entry, so a role typo in the shipped JSON cannot pass silently.

The checks the smoke prints

Tests cover what your code does. The rest of a mod - whether a code it names resolves, whether a string it needs was shipped - is only decidable once the game has loaded everything, which is what the smoke boot is for. Booting exmod smoke with both twintubblower and burdenmaker loaded (see Getting Started section 7) runs every content check in Checks against both domains.

Two of them matter most for a mega-block. MultiblockCodes confirms the footprint's own filler cells resolve once every def is injected. LangCoverage confirms every block- and blockdesc- key the def implies was actually shipped; a missing one shows the player its raw key in game, and nothing in the build would otherwise catch it. A clean run reads 0 error(s) on every line, for both domains, the same as any other mod in the family.