Skip to content

Add a block

Adding a block usually touches more than one file. Work through the full path so the new type can be stored, rendered, interacted with, and reconstructed consistently.

Start in src/Voxel/Blocks/ and choose a block with similar geometry or behavior. Check BlockProperties, its hooks, shape methods, drops, and any block entity it uses. Keep shared behavior in a base block class when multiple blocks already use that abstraction.

Add the block identity in src/Voxel/BlockType.cs following the surrounding conventions. Check how the world stores block identity and state before adding properties. Block types use compact IDs, and chunk cell state must stay compatible with the storage and save paths.

For property-driven behavior, declare the legal properties in the block’s state definition and use its default state and transitions. BlockState values are immutable registered flyweights; changing a property returns another registered state. Check how the active chunk storage adapter reads and writes that state before relying on a new property.

For example, FenceGateBlock declares a shared Boolean property and gives it a default in the state definition:

public static BooleanProperty OpenProperty => BlockStateProperties.Open;
protected override void CreateBlockStateDefinition(BlockStateDefinition.Builder builder)
{
builder.Add(OpenProperty, false);
}

The property setter returns a new registered state; it does not mutate the old one:

var gate = (FenceGateBlock)BlockRegistry.GetBlock(BlockType.OakFenceGate);
BlockState closed = gate.DefaultBlockState;
BlockState open = closed.SetValue(FenceGateBlock.OpenProperty, true);
bool isOpen = open.GetValue(FenceGateBlock.OpenProperty);

This only demonstrates state construction. A gameplay change must still pass the selected state through the level’s current storage API; the block-state guide explains that boundary.

Create a focused subclass in src/Voxel/Blocks/. Set material properties, hardness, sounds, collision/selection shapes, opacity, and light behavior in the block definition where those values belong. Override only the hooks needed for placement, interaction, neighbor changes, ticks, signals, or drops.

The AnvilBlock is a useful example of a focused subclass: it owns its texture selection and interaction, while its block entity holds per-position damage state. FenceGateBlock shows a property state definition and state transitions.

Register the shared block instance through the existing partial BlockRegistry in the registration file for that family. Check the existing item mapping, atlas indices, and asset import settings; reuse the established atlas and assets when possible. If a new item, recipe, block entity, or world-generation rule is part of the feature, wire each through its existing owner.

The gate family follows this pattern in BlockRegistry.SlabsStairsFencesWalls.cs:

Register(new FenceGateBlock(
BlockType.OakFenceGate,
"Oak Fence Gate",
TextureAtlas.OakPlanksTileIndex));

Avoid allocating per-cell Block objects. Registry instances describe a block type; per-position mutable data belongs in the block-state storage or an appropriate block entity.

Walk through placement, replacement, removal, drops, neighbor notification, scheduled work, mesh/light refresh, chunk unload, and save/load as applicable. A block that looks right when placed may still lose state after a world reload or leave stale geometry after a neighbor changes.

Add or extend focused coverage in src/Test/Suites/SystemValidation.Voxel.cs or the suite that owns the behavior. Then follow build and validation. Update the block system guide when the implementation changes shared architecture.

For a deeper look at property declarations, immutable transitions, and the current storage adapter, see block state in practice.