Chapter 14, Deeper States
Beta gives every block four bits of metadata. We want more. Here is how RetroAPI hands you up to 4096 states per block without breaking a single vanilla save.
Every block in Beta 1.7.3 carries a nibble of metadata, four bits, sixteen values, and that is the whole budget. A door uses some of it for orientation, a slab for which half is filled, wool for its color. Sixteen sounds like plenty right up until the moment you want a lamp that is both lit-or-not and tracking an age from 0 to 9, and suddenly twenty states won't fit in a space that holds sixteen. Mods have wrestled this corner of beta forever.
RetroAPI's answer is flattened blockstates, the same idea modern Minecraft adopted in 1.13, retrofitted onto a nibble-sized world. You declare a block's properties; the Cartesian product of their values becomes the block's set of states; each state gets a single flattened index. The low four bits of that index ride vanilla metadata exactly where the game expects them. The bits above, 4 through 11, the part we call secondary meta or xmeta, live in a sidecar that RetroAPI keeps next to the chunk. That buys you eight extra bits, so up to 4096 states per block. Declare more than that and registration throws, on purpose, before a broken world can exist.
The honest promise underneath: a block with 16 or fewer states stores byte-for-byte identically to plain metadata. The xmeta sidecar only appears once a block actually needs bit 4. You pay for the extra states only when you use them.
Declaring properties
A property is a named axis with a fixed list of values. Three kinds cover almost everything, all from package com.periut.retroapi.state:
| Property | Values |
|---|---|
RetroBoolProperty.of("lit") | exactly two: false, then true |
RetroIntProperty.of("age", 0, 9) | the inclusive integer range, here ten values 0..9 |
RetroEnumProperty.of("facing", MyEnum.class) | one value per enum constant, in declaration order |
Two rules about order are worth tattooing somewhere, because the flattened index depends on both. First, the first value of every property is its default, so the block's default state is "every property at its first value." Second, the first-declared property varies slowest in the index, exactly like digits in a number: the last property you list is the ones place. Enum constants serialize to their lowercase name unless the enum implements RetroNameable.getName() to say otherwise.
You hand the properties to the builder with .states(...) before .register(). An optional .defaultState(...) overrides which combination counts as the default if "all-first-values" isn't what you want:
MY_BLOCK = RetroBlockAccess.of(new MyBlock(RetroBlockAccess.allocateId()))
.states(LIT, AGE)
.defaultState(s -> s.with(LIT, false)) // optional
.register(id("my_block"));Reading and writing state
All world access goes through RetroStates. The two you reach for constantly are get and set:
| Call | What it does |
|---|---|
RetroStates.get(world, x, y, z) | the RetroBlockState at a position, or null for air / invalid |
RetroStates.set(world, x, y, z, state) | writes meta + xmeta, marks the chunk dirty, re-renders, syncs to clients |
RetroStates.getDefault(block) | the block's default state |
RetroStates.fromIndex(block, index) | the state for a raw flattened index |
RetroStates.property(block, "lit") | look a property up by name |
RetroStates.stateCount(block) | how many states the block has |
The state object itself, RetroBlockState, is immutable and interned per block. That second word is the gift: there is exactly one instance per distinct state, so == is a valid comparison and you never worry about equals. You navigate the state space with with, which returns the sibling instance:
| Method | Returns |
|---|---|
state.get(PROP) | the value of one property in this state |
state.with(PROP, value) | the interned sibling state with that one property changed |
state.getIndex() | the flattened index |
state.getBlock() | the owning block |
A state prints itself the way you'd hope, block name plus a sorted property list: tile.example_mod.lamp[lit=true,age=7]. That string is exactly what the lamp drops into chat below.
Blocks you never wrote state code for
Here is the quietly clever part. A block that never calls .states() still gets a single implicit meta property running 0 to 15, lazily, the first time anything queries its state. That means a blockstate JSON with variant keys like "meta=2" just works on a completely ordinary block, with zero lines of code on your side. Vanilla's sixteen-value nibble becomes a property you can address by name, for free.
And properties don't have to come from Java at all. A blockstate JSON may declare them itself:
{
"properties": {
"facing": ["north", "south"],
"lit": "boolean",
"age": { "min": 0, "max": 7 }
}
}If both code and data declare a property, code wins and a warning is logged, so a stray JSON can never silently reshape a block your Java already pinned down.
There is no opt-in for crossing the 16-state line, in code or in data: the moment a definition needs more than the nibble, the extra bits ride secondary meta automatically. That is safe to leave silent because xmeta costs nothing until a high bit is actually set (the sidecar stores only nonzero entries, and old worlds load unchanged), and the one genuinely dangerous case, a definition past 4096 states, throws at registration instead.
Writing a custom render type? Reach the current state from inside the render lambda with ctx.getState(). That is how a renderer draws "lit" differently from "not lit" without touching the world again.
Storage and sync, the one honest paragraph
You will never touch any of this, but you should know it exists. The world save's inventory sidecar moves to format v3, which adds an optional xmeta array per chunk; a v2 world (no mod, or an older mod) loads completely unchanged, and chunks with no state above 15 never grow the array. The full-chunk network packet carries that xmeta alongside the metadata; a single-block change instead rides a small dedicated packet on the retroapi:state_sync channel, which is what RetroStates.set broadcasts on a dedicated server. And when StationAPI is present, the xmeta store keeps working through it. None of these words ever appear in your mod. You call get and set; the sidecar, the packet, and the channel are RetroAPI's problem.
The walkthrough: the State Lamp
The showcase ships a lamp built to land squarely past the nibble: two values of lit times ten values of age is twenty states, four more than metadata can hold. Right-clicking it walks the whole state space, bumping age each click and flipping lit when age wraps, printing the state and its flattened index to chat, and visibly swapping the model when lit changes. Here is the block in full:
package com.example.example_mod;
import com.periut.retroapi.state.RetroBlockState;
import com.periut.retroapi.state.RetroBoolProperty;
import com.periut.retroapi.state.RetroIntProperty;
import com.periut.retroapi.state.RetroStates;
import net.minecraft.block.Block;
import net.minecraft.block.material.Material;
import net.minecraft.entity.player.PlayerEntity;
import net.minecraft.world.World;
/**
* The state lamp: flattened blockstates and JSON models working together.
*
* Properties are declared in code (.states(LIT, AGE) at registration); 2 x 10 = 20
* states, deliberately MORE than the 16 a vanilla meta nibble can hold, so the
* extra bits ("secondary meta") ride RetroAPI's region sidecar and sync channels.
* You never see any of that: RetroStates.get/set are the whole API.
*
* The look comes from assets/example_mod/retroapi/blockstates/lamp.json, which
* maps lit=false / lit=true to two cube_all models. Because that JSON exists,
* the block was auto-wired to the model render type at registration; no render
* code anywhere in this class.
*
* Right-click to walk the state space and watch the chat line + the model flip.
*/
public class ExampleLampBlock extends Block {
public static final RetroBoolProperty LIT = RetroBoolProperty.of("lit");
public static final RetroIntProperty AGE = RetroIntProperty.of("age", 0, 9);
public ExampleLampBlock(int id) {
super(id, Material.STONE);
}
@Override
public boolean onUse(World world, int x, int y, int z, PlayerEntity player) {
RetroBlockState state = RetroStates.get(world, x, y, z);
if (state == null) {
return false;
}
// Bump age each click; toggle lit when it wraps. with() returns the interned
// sibling state, set() writes meta + xmeta, marks dirty and syncs to clients.
int age = state.get(AGE);
RetroBlockState next = age == 9
? state.with(AGE, 0).with(LIT, !state.get(LIT))
: state.with(AGE, age + 1);
RetroStates.set(world, x, y, z, next);
if (!world.isRemote) {
player.sendMessage("lamp is now " + next + " (flattened index " + next.getIndex() + ")");
}
return true;
}
}Read onUse slowly, because it is the entire API in miniature. RetroStates.get hands back the current state (or null, which we guard). We read age, and if it's at the top of its range we wrap it to 0 and flip lit with two chained with calls, otherwise we just bump age. Every with returns the interned sibling, no allocation churn, no equals. Then RetroStates.set commits the new state: it writes the low bits to vanilla meta, the high bit of lit to xmeta, marks the chunk dirty, re-renders, and broadcasts on the sync channel if we're a server. The chat line prints, for example, tile.example_mod.lamp[lit=true,age=7] (flattened index 17).
The registration is where the twenty states are declared, with .states(LIT, AGE):
// Flattened blockstates + JSON models: the lamp declares 20 states in code
// and gets its two looks from blockstates/lamp.json. See ExampleLampBlock.
LAMP = RetroBlockAccess.of(new ExampleLampBlock(RetroBlockAccess.allocateId()))
.sounds(Block.STONE_SOUND_GROUP)
.strength(0.5f)
.alwaysEffectiveTool()
.alwaysDrops()
.states(ExampleLampBlock.LIT, ExampleLampBlock.AGE)
.register(id("lamp"));Because LIT is declared first, it varies slowest: states 0 through 9 are lit=false with age climbing, states 10 through 19 are lit=true with age climbing again. The lit flip at state 10 is the one that crosses bit 4 into xmeta, the visible proof that we left the nibble behind. And notice there is no render code anywhere in the lamp: it gets its two looks from a blockstate JSON that maps lit to a model, which is Chapter 16's whole story. The block half is done; the picture half is next.
The wall: 162 states in the wild
The lamp was a clean twenty states to make the point. The wall is what the point is for. The showcase ports matthewperiut/stonewalls-fabric-b1.7.3, a StationAPI mod, to RetroAPI as ExampleWallBlock, and it lands at 162 states. Five properties do it: up is a boolean for the center post, and each of the four sides carries a none/low/tall WallShape enum. That is 2 × 34 = 162, more than ten times the nibble, so the overwhelming majority of these states live entirely in secondary meta.
The enum is three constants, no more:
package com.example.example_mod;
/**
* The shape of one side of a wall: not connected, a low segment, or a full-height
* segment (when something sits on the junction). Enum constants serialize as their
* lowercase names, which is what the blockstate JSON's "north": "low" keys match.
*/
public enum WallShape {
NONE,
LOW,
TALL
}The block itself is mostly lifecycle. A wall's connections are not stored by hand, they are state recomputed from the neighborhood, and the whole trick is in three overrides plus the determineState that feeds them. Here is the class, with the shape and bounds helpers trimmed (they are collision geometry, pure code, and not the state story):
/**
* The stone wall: the real-world blockstate workout. Ported from
* matthewperiut/stonewalls-fabric-b1.7.3 (a StationAPI mod) to RetroAPI.
*
* Five properties: up (the center post) and a none/low/tall shape per side, which
* makes 2 x 3 x 3 x 3 x 3 = 162 states, an order of magnitude past the vanilla
* nibble, so most of them live in secondary meta. The blockstate JSON is a
* MULTIPART file straight out of the modern wall format: the post applies when
* up=true, a rotated side segment applies per connected direction, and the tall
* variant applies when something sits on the junction.
*
* The interesting lifecycle: connections are STATE, recomputed whenever the
* neighborhood changes. onPlaced computes the initial state; neighborUpdate
* recomputes it on every block update next door; RetroStates.set only fires when
* the state actually changed (states are interned, so != is exact), which both
* avoids redundant work and terminates the update ripple two walls would
* otherwise bounce between forever.
*/
public class ExampleWallBlock extends Block {
// fields, constructor, canConnect helper ...
/** Computes what this wall's state should be from its neighborhood. */
public RetroBlockState determineState(World world, int x, int y, int z) {
boolean up = true;
boolean north = canConnect(world, x, y, z - 1);
boolean south = canConnect(world, x, y, z + 1);
boolean west = canConnect(world, x - 1, y, z);
boolean east = canConnect(world, x + 1, y, z);
// A straight run (and nothing else) hides the post.
boolean nsWall = north && south && !(west || east);
boolean weWall = west && east && !(north || south);
boolean canUpBeFalse = nsWall || weWall;
boolean tallNorth = false;
boolean tallSouth = false;
boolean tallWest = false;
boolean tallEast = false;
int aboveId = world.getBlockId(x, y + 1, z);
Block above = (aboveId > 0 && aboveId < Block.BLOCKS.length) ? Block.BLOCKS[aboveId] : null;
if (above instanceof ExampleWallBlock) {
if (canUpBeFalse) {
// Match the wall above: a post up there forces a post down here.
RetroBlockState aboveState = ((ExampleWallBlock) above).determineState(world, x, y + 1, z);
up = aboveState.get(UP);
}
tallNorth = canConnect(world, x, y + 1, z - 1);
tallSouth = canConnect(world, x, y + 1, z + 1);
tallWest = canConnect(world, x - 1, y + 1, z);
tallEast = canConnect(world, x + 1, y + 1, z);
} else {
up = !canUpBeFalse;
}
if (above != null && above.isFullCube()) {
tallNorth = true;
tallSouth = true;
tallWest = true;
tallEast = true;
}
return RetroStates.getDefault(this)
.with(UP, up)
.with(NORTH, north ? (tallNorth ? WallShape.TALL : WallShape.LOW) : WallShape.NONE)
.with(SOUTH, south ? (tallSouth ? WallShape.TALL : WallShape.LOW) : WallShape.NONE)
.with(WEST, west ? (tallWest ? WallShape.TALL : WallShape.LOW) : WallShape.NONE)
.with(EAST, east ? (tallEast ? WallShape.TALL : WallShape.LOW) : WallShape.NONE);
}
/** Recomputes and writes the state, but only when it actually changed (interned ==). */
private void refresh(World world, int x, int y, int z) {
RetroBlockState current = RetroStates.get(world, x, y, z);
RetroBlockState next = determineState(world, x, y, z);
if (current != next) {
RetroStates.set(world, x, y, z, next);
}
}
@Override
public void onPlaced(World world, int x, int y, int z) {
super.onPlaced(world, x, y, z);
refresh(world, x, y, z);
}
@Override
public void neighborUpdate(World world, int x, int y, int z, int neighborId) {
refresh(world, x, y, z);
// A wall below may need to grow or lose its post because of us.
int belowId = world.getBlockId(x, y - 1, z);
if (belowId > 0 && belowId < Block.BLOCKS.length && Block.BLOCKS[belowId] instanceof ExampleWallBlock) {
((ExampleWallBlock) Block.BLOCKS[belowId]).refresh(world, x, y - 1, z);
}
super.neighborUpdate(world, x, y, z, neighborId);
}
// shape(), offset(), getCollisionShape, getBoundingBox, updateBoundingBox ...
}Read that as one loop. determineState looks at the four horizontal neighbors with canConnect (a full cube or another wall), decides whether each side is connected, then whether it should be tall because something sits on the junction above, and folds the post rule (a straight run hides the post) on top. It returns an interned state, never a mutation. onPlaced runs determineState once to set the freshly placed wall's shape; neighborUpdate runs it again on every block update next door, and also nudges the wall directly below in case our placement changed its post.
The line that makes this safe is in refresh: if (current != next), then and only then does RetroStates.set fire. Because states are interned, that != is an exact identity check, not a field-by-field compare, and it is what terminates the ripple. Two adjacent walls each see the other update and recompute, but the second time around the recomputed state equals what is already there, set is skipped, and no further neighbor update is emitted. Without that guard, two touching walls would bounce updates between each other forever.
The propagation cuts both ways, which is exactly what you want. RetroStates.set does a setBlockDirty plus notifyNeighbors, so a real state change re-renders the block and fires block updates outward, which reach the next wall's neighborUpdate, which recomputes it, and so on down a chain. Place a wall at the end of a long run and the whole run reshapes in one tick. A no-op set emits nothing, so the cascade stops the instant the shapes stabilize.
Registration declares the five properties with .states(...), in the order that fixes the flattened index, and overrides the default so a lone wall stands as a post:
// THE blockstate workout: a cobblestone wall ported from
// matthewperiut/stonewalls-fabric-b1.7.3. Five properties (up + a
// none/low/tall shape per side) make 162 states; connections are
// recomputed from the neighborhood on placement and on every block
// update (see ExampleWallBlock), and the multipart blockstate JSON
// assembles post + rotated side models per state. The models reference
// minecraft:block/cobblestone, resolved straight off the vanilla atlas.
STONE_WALL = RetroBlockAccess.of(new ExampleWallBlock(RetroBlockAccess.allocateId()))
.sounds(Block.STONE_SOUND_GROUP)
.strength(2.0f, 10.0f)
.nonOpaque()
.states(ExampleWallBlock.UP, ExampleWallBlock.NORTH, ExampleWallBlock.EAST,
ExampleWallBlock.WEST, ExampleWallBlock.SOUTH)
.defaultState(s -> s.with(ExampleWallBlock.UP, true))
.mineable(com.periut.retroapi.tag.RetroTool.PICKAXE)
.register(id("stone_wall"));And the recipe is the classic two rows of cobblestone, lifted straight from the original mod:
// The classic wall recipe, straight from the stonewalls mod: two rows of
// cobblestone craft six walls.
RetroRecipes.addShaped(new ItemStack(STONE_WALL, 6),
"CCC",
"CCC",
'C', Block.COBBLESTONE);That is 162 states with no bespoke storage, no packet code, and no manual connection bookkeeping, the same two get/set calls the lamp used, scaled up by the state count alone. The 162-way multipart blockstate JSON that turns each of those states into post-plus-sides geometry is Chapter 16's, see the multipart section there.
What we built
You declared properties, multiplied them into a state space larger than beta was ever meant to hold, and read and wrote those states with two method calls that hide a sidecar, a chunk packet, and a sync channel. Plain blocks got a free meta property so old-style variant JSONs keep working; data files can declare properties of their own, and your code always wins the tie. Twenty states for the lamp, 162 for the wall, sixteen bits of room, and the world still opens in vanilla.
The lamp visibly changed its look when lit flipped, and we waved at the reason without explaining it. That explanation, blockstate JSONs, model JSONs, render layers, tints, and animated textures, all working in beta, is the next and final building chapter. See Chapter 4 for where blocks began; now let's give them modern shapes and pixels.

