Multiblock Structures
A multiblock is a machine bigger than one block. Two quite different things go by that name, and exlib carries both.
The first is a single block with an oversized model: a blower, a boiler, a press whose shape sticks out into the cells around it. The game gives such a block collision and a selection box in its own cell only, so the player walks straight through the half of the machine that is only a model, and can build in the space it appears to occupy. exlib fills the rest of the shape with an invisible filler block that has real collision, and reroutes everything a player can do to a filler cell back to the block that owns it. A filler cell can also carry a behaviour of its own, so the corner of a machine can be where its power port or its pipe connector lives.
The second is a machine the player builds out of ordinary blocks, to a design you publish: a blast
furnace of firebricks around a core, or the SmokeStack sample's 72-cell chimney. There you need to
know whether the player got it right, to keep the machine from running while it is wrong, and to
show what is missing rather than leave the player counting bricks. exlib gives you a base block
entity - a block entity being the object the game attaches to a placed block to hold its state and
run code - that re-checks the pattern on a timer, calls you on each transition between complete and
incomplete, and draws a hologram of the missing blocks when the player ctrl+shift+right-clicks.
Take either without the other, or both at once. Both start from a shape, drawn once as ASCII art in C# or in JSON, and neither asks you to think about rotation: you author facing north and exlib rotates the layout to the angle the block was placed at.
One grid, three uses
You draw a shape as rows of characters, one character per world cell, and say in a legend what each
character means. Both builders on this page - MultiblockLayoutBuilder.Layer/Slice/Face for the
pattern a player must build, and FillerLayoutBuilder.Layer/Slice/Face for a mega-block's
footprint - draw over one core, ExpandedLib.Structures.CellGrid, so what you learn here serves
both.
The three verbs are three ways to cut the same box; pick whichever makes your shape easiest to read.
Layer is a floor plan, rows +Z and columns +X, one grid per Y level, and it suits anything wide and
low. Slice is a front elevation at a fixed X (rows -Y from the top, columns +Z) and Face the
same at a fixed Z, looking along -Z (rows -Y from the top, columns +X); both suit anything tall,
where a floor plan would mean a dozen near-identical grids. One layout may mix them: draw the floor
with Layer and add a chimney with Slice in the same builder.
.Slice(0, """
M##
0##
""")
draws a two-cell-tall, three-wide elevation at X=0: the top row's leftmost cell is M, the bottom
row's leftmost is the principal (0), the cell the block itself stands in and the origin every
other cell is measured from. SymbolLegend<T> is the matching legend core: it maps a grid's symbols
to whatever a builder resolves them into. It takes one policy for a symbol mapped twice - Throw
for a code-first layout, where a duplicate is a mistake, Replace for a filler footprint, where a
later declaration is meant to win - and it reports a symbol declared but never drawn, which is how a
typo in a grid surfaces at build time rather than in game.
The filler system
A "mega-block" is one block whose model spans more than its own cell. The filler system gives it
collision everywhere it looks solid, by placing invisible exlib:structurefiller blocks in the
other footprint cells. They render nothing, they are solid, they are hidden from the handbook, and
every player-facing operation on one is forwarded to the block that owns the footprint.
Declaring the footprint
The footprint is a list of cell offsets, and the block that owns it is the principal, or
controller. exlib reads that list through IFillerHost, which you rarely implement yourself:
BlockFilledMegastructure already does, by reading the block's Attributes["fillerOffsets"], so a
block deriving from it only has to supply that attribute. Supply it in JSON, or code-first from an
ExBlockDef builder with FillerOffsets(IEnumerable<FillerCellSpec>), as BlockBoilerCornish
does:
.FillerOffsets(
StructureFootprint.Layout(f =>
f.Origin(-1, -5)
.Slab('_', BlockFacing.DOWN)
.Slab('M', BlockFacing.DOWN)
.Solid('I')
.Port('S', BlockFacing.UP, "pipe")
.Port('E', BlockFacing.EAST, "pipe")
.Layer(0, """
# # E
# # #
...
""")
)
)
allowAttach (default false) decides whether other blocks may attach to that filler cell. Leave
it off for the body of a machine, so a player cannot hang a ladder off thin air; turn it on where
the shape is a platform or a wall you want treated as real.
Placing and removing fillers
Something still has to put the fillers in the world when the machine is placed and take them out
when it is broken. StructureFillers is that helper. It resolves the declared offsets against a
position and a placement angle - you author them in the block's north orientation and it rotates
them for you - and then places, checks or clears the cells.
public static class StructureFillers
{
public static AssetLocation FillerCode { get; set; } // default "exlib:structurefiller"
public static List<FillerOffset> ReadOffsets(JsonObject? offsetsNode);
public static List<FillerCell> FootprintCells(IFillerHost principal, BlockPos principalPos, int angle);
public static bool CanPlace(IWorldAccessor world, IEnumerable<FillerCell> cells);
public static void PlaceFillers(IWorldAccessor world, BlockPos principalPos, IEnumerable<FillerCell> cells);
public static void RemoveFillers(IWorldAccessor world, BlockPos principalPos, IEnumerable<FillerCell> cells);
}
public readonly struct FillerOffset { public Vec3i Offset { get; } public bool AllowAttach { get; } }
public readonly struct FillerCell { public BlockPos Pos { get; } public bool AllowAttach { get; } }
The flow in the controller block is two calls at placement and one at breaking. Check the footprint is clear before you place anything, so a half-placed machine cannot exist, and clear the fillers on break, so the player is not left with invisible solid cells in the air:
// In TryPlaceBlock: bail if the footprint isn't clear.
var cells = StructureFillers.FootprintCells(this, blockSel.Position, placeAngle);
if (!StructureFillers.CanPlace(world, cells)) { failureCode = "notenoughspace"; return false; }
// ...place the controller block, then:
StructureFillers.PlaceFillers(world, blockSel.Position, cells);
// In OnBlockBroken: clear the fillers linked to this controller.
StructureFillers.RemoveFillers(world, pos, cells);
PlaceFillers and RemoveFillers are server-side. RemoveFillers only clears cells actually
linked to the given principal, so two mega-blocks standing shoulder to shoulder do not tear holes in
each other when one is broken.
Note
Per-cell collision gotcha. The filler block must declare sidesolid: true (and a real
collision box) for the engine to treat each cell as solid. Without it the mega-block has only
single-cell collision regardless of fillers.
Per-cell interactions (optional)
By default a click anywhere on the footprint is a click on the controller, which is what you want
most of the time. When the cells should mean different things - a hatch at the front, a hopper on
top - the controller implements IFillerInteractionTarget. The filler then forwards the click along
with the position of the cell that was clicked, and you decide from there:
public interface IFillerInteractionTarget
{
bool OnFillerInteractStart(IWorldAccessor world, IPlayer byPlayer, BlockSelection principalSel, BlockPos clickedCell);
bool OnFillerInteractStep(float secondsUsed, IWorldAccessor world, IPlayer byPlayer, BlockSelection principalSel, BlockPos clickedCell);
void OnFillerInteractStop(float secondsUsed, IWorldAccessor world, IPlayer byPlayer, BlockSelection principalSel, BlockPos clickedCell);
WorldInteraction[] GetFillerInteractionHelp(IWorldAccessor world, BlockSelection principalSel, IPlayer forPlayer, BlockPos clickedCell);
}
BlockStructureFiller and BlockEntityStructureFiller, the invisible block and its block entity,
reroute break, pick, drops, sounds, HUD info and interaction help to the controller with no code
from you. The block entity stores the Principal position link, which is how a filler knows whose
cell it is, plus optional network-port config (PortFace, PortNetworkType). That last pair is
what lets a filler cell expose a network connector on the controller's behalf, so
a pipe or a power shaft joins the machine at the cell where its model actually has a socket, not at
the controller's own cell.
Completion monitoring: BlockEntityMultiblockStructure
The second half of the page starts here, independent of everything above. When the machine is
something the player assembles out of ordinary blocks - a blast furnace, a cowper stove, a bessemer
control - subclass BlockEntityMultiblockStructure. It gives you the form alone: a monitor tick
that re-checks the pattern every few seconds and notices when it becomes complete or is breached,
and the answer published as readiness through IProductionReadiness, which anything that should run
only on a finished machine can ask. The three abstract members are what only you can answer: which
way the structure faces, and what to tell the player in each state.
public abstract class BlockEntityMultiblockStructure : BlockEntity, IProductionReadiness
{
public bool StructureComplete { get; protected set; }
protected virtual int CompletionTickMs { get; } // monitor interval, default 3000ms
protected virtual bool CanRunProduction { get; } // production runs only while complete
protected virtual bool StopsProductionOnStructureLost { get; } // false keeps a breached machine ticking
public virtual void Interact(IPlayer byPlayer); // toggle the build-outline projection
// You implement these:
protected abstract void UpdateStructureRotation();
protected abstract string GetIncompleteMessage(int missingCount);
protected abstract string GetCompleteMessage();
// Optional hooks:
protected virtual void OnStructureLost(); // complete -> incomplete
protected virtual void OnStructureCompleted(); // incomplete -> complete
protected virtual BlockPos GetGlobalPos(int localX, int localY, int localZ);
protected void SetStructureAngle(int angle, int initAngleOffset = 0); // canonical UpdateStructureRotation body
}
From JSON only
A machine that is just a designed shape - no production tick, no per-cell behaviour - needs no C# at
all. The classes above are registered under names the game's own blocktypes/ JSON can ask for.
Three rungs, shortest first:
- Zero-config:
"class": "ExFilledMegastructure"plus"entityClass": "ExMultiblock"and theMultiblockStructurebehaviour give per-cell collision, the completion monitor and the incomplete/complete messages out of the box. - Declarative: an
attributes.multiblockLayoutASCII grid, the JSON twin ofMultiblockLayoutBuilder, states the shape in one place.fillerOffsetsis derived from it, as every drawn cell but the principal's own, unless you declare your own. A structure the player builds out of real blocks rather than fillers must declare"fillerOffsets": []. Without that, a derived footprint reserves every drawn cell with an invisible filler and the player can place nothing where the design says to.
{
"class": "ExFilledMegastructure",
"entityClass": "ExMultiblock",
"behaviors": [{ "name": "MultiblockStructure" }],
"attributes": {
"multiblockLayout": {
"origin": [1, 0],
"legend": { "C": "mymod:kiln-core", "B": "mymod:kiln-brick" },
"layers": [["BBB", "BCB", "BBB"], ["B.B", "...", "B.B"]],
"core": "C"
}
}
}
Orientation follows the block's own side or orientation variant, the interchangeable segment of
a block code that says which way a placed block faces. The messages are your domain's own
multiblock-<blockpath>-incomplete and -complete lang keys when you declare them, else exlib's
own. A malformed multiblockLayout logs one Error naming the block and the problem, and the
structure simply never completes; it does not throw at chunk load, so one bad grid cannot take a
world down.
- The explicit API: for anything the grid cannot say - roles, connectors, oriented parts, a
production tick - drop to the code-first
ExBlockDefbuilder above, or subclassBlockEntityMultiblockStructureyourself.
Multiblocks that also produce: BlockEntityMultiblockMachine
Running a production tick is a separate choice from being a multiblock, and it is the reason there
are two base classes rather than one. A stone archway is a multiblock that produces nothing. A
furnace is both. Subclass BlockEntityMultiblockMachine when you want both: it hosts the same
BEBehaviorProductionMachine every other machine in the framework runs,
registers the tick on load only when the structure is already complete, and lets the monitor tick
start and stop it as the structure is finished or breached. So a machine cannot run on a design the
player has half dismantled, and you never write that check. A structure that only has to be built
subclasses the form above and carries no tick at all.
public abstract class BlockEntityMultiblockMachine : BlockEntityMultiblockStructure
{
protected virtual int ProductionTickMs { get; } // tick interval, default 1000ms
protected virtual bool AutoStartProduction { get; } // register on load if already complete
protected virtual int MaxAwayCatchupSteps { get; } // 0 disables the unloaded-time replay
protected abstract void OnProductionTick(float dt);
protected virtual void OnIdleProductionTick(float dt);
}
ProductionTickMs is how often your work runs; raise it for anything that need not look smooth.
AutoStartProduction decides whether a machine found complete when its chunk loads starts working
at once, which suits a furnace but not a machine that stays dormant until a player switches it on.
MaxAwayCatchupSteps caps the replay of time that passed while nobody was there, so a player
returning after a week gets a plausible amount of output rather than a week of it; 0 turns the
replay off. A minimal subclass:
[BlockEntityRegister]
public class BlockEntityBlastFurnace : BlockEntityMultiblockMachine
{
protected override void UpdateStructureRotation()
=> SetStructureAngle(ExOrientation.AngleFromSide(Block.Variant["side"]));
protected override string GetIncompleteMessage(int missingCount)
=> Lang.Get("siex:blastfurnace-incomplete", missingCount);
protected override string GetCompleteMessage()
=> Lang.Get("siex:blastfurnace-complete");
protected override void OnProductionTick(float dt) { /* smelt while complete */ }
}
How completion is wired
The pattern itself - which cell must hold which block - is not exlib's format. It is a vanilla
multiblockStructure JSON definition, the game's own, referenced by your block; the layout
builders on this page write one for you. Your job is to tell exlib which way it faces, and that is
all UpdateStructureRotation is for. SetStructureAngle is its canonical body: it loads that JSON,
calls the engine's InitForUse at the angle you give, and clears any stale projection so a rotated
machine does not keep showing the old one. From there the monitor tick re-checks completeness every
CompletionTickMs and calls OnStructureCompleted or OnStructureLost only on a transition, never
on every tick, so those two are safe places to play a sound or send a message.
Note
GetGlobalPos / _currentAngle invariant. The base GetGlobalPos(angle) is equivalent to
InitForUse(angle), so the angle you pass to SetStructureAngle must match the structure's
_currentAngle. A mismatch shows up as the build outline appearing rotated 180 deg.
The build-outline behaviour
A player who has mis-built a structure needs to see where, and the answer is a hologram of the
missing blocks, toggled on ctrl+shift+right-click. That toggle lives in
BlockBehaviorMultiblockStructure, a BlockBehavior rather than a base class - a component listed
in the block's definition, so it works whatever your block already derives from. List it before
any other consumer of right-click, or that consumer swallows the click first:
"behaviors": [ { "name": "MultiblockStructure" } ]
It calls back into the block entity's Interact, which re-checks completeness and then either shows
the outline of the missing parts or, if the structure has since been finished, clears it. The
highlighting is a reimplementation rather than a call into vanilla's, and the reason is a crash:
vanilla's HighlightIncompleteParts throws IndexOutOfRange when a wanted blockNumbers code
resolves to no block at all, taking the client down. The base class falls back to a neutral tint.
Note
blockNumbers validity. Every offset in your multiblockStructure definition needs a
blockNumbers entry that resolves to at least one real block, or the build outline (and vanilla
highlighting) misbehaves.
Orientation-checked parts
Many vanilla blocks carry their facing in their code: a slab is brickslabs-fire-south-free, and
the south is which way it points. That makes a trap easy to fall into. Vanilla rotates a
structure's offsets through InitForUse(angle) but never its codes, so a layout asking for
brickslabs-fire-south-free demands a south-facing slab at every structure angle - wrong three of
the four ways the player can place the machine. The usual escape is -*, "any rotation", and it is
worse than it looks: a structure built with it reports itself complete while its walls still have
visible gaps, because a slab facing outward satisfies the check as happily as one facing in.
exlib closes that off. A legend code containing a whole horizontal side segment (north, south,
east, west, or the letters n, s, e, w) is orientation-checked: the facing it demands
rotates with the structure. You keep authoring in the north-default frame, and all four placements
work.
.Legend('i', "game:brickslabs-fire-south-free") // south at angle 0, east at 90, ...
.Legend('-', "game:brickslabs-fire-up-free") // vertical - a Y rotation cannot move it
.Legend('#', "game:claybricks-good-fire") // no facing - untouched
.LegendAnyFacing('x', "mod:thing-north") // opt out: accept the literal code at any angle
How it works, and why it costs almost nothing:
MultiblockLayoutBuilderrecords which dash-segment of each oriented code is the facing word, into a sibling attributeattributes.multiblockFacings. It is a sibling rather than a member ofmultiblockStructure, because that object is deserialised by vanilla's ownMultiblockStructureand must stay exactly its schema.MultiblockFacings.Rotateswaps that segment for the structure-rotated one (ExOrientation.RotateSideWord, the string counterpart ofRotateFacingand sharing its convention: a part authorednorthreads as the side whoseAngleFromSideequals the structure's angle).BlockEntityMultiblockStructure.IncompleteBlockCountreplaces vanilla'sInCompleteBlockCountand routes every wanted code through that rotation. The build outline shares the same resolver, so the tint and the count can never disagree - and an oriented slot now resolves to a real, correctly-facing block, so the outline colours from the right variant and the missing-blocks report names it.
Three things to know:
- Only whole segments count.
westwardor a-we-axis token is not mistaken for a facing, and the last matching segment wins, because block codes put the orientation at the end. - Codes are keyed in full domained form.
AssetLocation.ToShortString()elidesgame:, so a table keyed the way an author typed it would never match a vanilla block at runtime. - Trapdoors cannot be checked this way.
game:trapdoorkeeps its facing and open/closed state in its block entity, not its code, so no code match can see it. Slabs, stairs, doors andcokeovendoorall carry theirs in the code and work fine.
A layout that declares no oriented part emits no attribute at all, and none of this machinery runs for it.
Cell roles
A structure's code often needs to find a particular cell of the pattern: where the tuyeres go, where
to spawn smoke, which cell takes fuel. Hard-coded coordinates break the moment the machine is
rotated or the layout is edited. A CellRole is the alternative: an open string key naming what a
cell is for, attached to a glyph in the layout and read back as world positions, already rotated.
exlib declares no roles of its own; mint the ones your machine needs with CellRole.Of, mark a
glyph with MultiblockLayoutBuilder.Role, and read them through
BlockEntityMultiblockStructure.CellsWithRole:
public static readonly CellRole Tuyere = CellRole.Of("tuyere");
// ...
.Role('t', Tuyere)
// ...
IReadOnlyList<BlockPos> tuyereCells = CellsWithRole(Tuyere);
Pass single: true to CellRole.Of when a layout may give the role at most one cell, such as a
single fuel hatch. The arity is then enforced at build time, and your code may call
CellsWithRole(role).Single() without guarding against a second one.
Related pages
- Production Machines - the tick lifecycle
BlockEntityMultiblockMachinebuilds on, and how to host it when your base class is already spent. - Block Networks - how a structure joins a pipe or power graph. One that is also a network node must call
AddNode/RemoveNodeitself. - Code-First Definitions - the
ExBlockDefbuilder that carriesMultiblockLayoutandFillerOffsets. - Helpers & Renderers -
ExOrientationfor the rotation math,SurfaceRendererfor fluid surfaces.