Chapter 20, New Lands

Stop borrowing the Overworld's terrain and grow your own: a chunk generator from one equation, then a full Perlin world with ores, caves, trees, biomes, and a portal home.

Chapter 10 registered a dimension and walked into it through a portal, and it was honest about reusing overworld generation so the chapter could stay about doorways. This is the other half. Here you bring your own generator into the room, and you learn the second large subject Chapter 10 set aside: chunk generators, biome sources, surface decoration, and the spawn rules that make a world feel alive. The showcase ships two of these. The Curvy dimension is the smallest possible custom generator, a single equation. The Noisy dimension is the real thing, Perlin terrain with everything bolted on. We'll build up from the equation to the full world, then wire the biomes, the spawn caps, and the portal that takes you back.

A chunk generator from one equation

A custom dimension overrides one method, createChunkGenerator(), to hand back a ChunkSource of your own. The smallest useful generator has no noise at all, just an equation that says how high the ground is at any (x, z). The Curvy dimension's surface is a scaled-up sin(x) + cos(z), which makes a smooth rolling egg-carton of hills:

src/main/java/com/example/example_mod/worldgen/CurvyChunkSource.java, getChunk()
// THE equation. Scaled-up sin(x) + cos(z), rounded to a block height.
double curve = Math.sin(worldX / PERIOD) + Math.cos(worldZ / PERIOD);
int surface = BASE + (int) Math.round(AMPLITUDE * curve);

for (int y = 0; y < 128; y++) {
	int id;
	if (y == 0) {
		id = Block.BEDROCK.id;       // unbreakable floor
	} else if (y == surface) {
		id = Block.GRASS_BLOCK.id;    // one grass cap
	} else if (y >= surface - 3 && y < surface) {
		id = Block.DIRT.id;           // three dirt under the grass
	} else if (y < surface) {
		id = Block.STONE.id;          // stone all the way down
	} else {
		id = 0;                       // air above the surface
	}
	blocks[index(lx, y, lz)] = (byte) id;
}

That inner loop is the heart of every beta chunk generator: walk a column from y = 0 to 127 and decide what block each cell holds. A beta chunk is 16 x 128 x 16 blocks packed into one flat byte[], and the index formula is the thing to memorize, (x * 16 + z) * 128 + y:

src/main/java/com/example/example_mod/worldgen/CurvyChunkSource.java, index()
private static int index(int x, int y, int z) {
	return (x * 16 + z) * 128 + y;
}

Once the array is full, you wrap it in a Chunk, build its height map, and hand it back. Don't call new Chunk(...) directly: under StationAPI a raw legacy Chunk is unsavable, because StationAPI flattens chunks into its own format. Instead create the chunk through RetroWorldGen.createChunk(...), which returns the right type for the environment, a StationAPI FlattenedChunk when StationAPI is present, a legacy Chunk otherwise. Your generator stays identical either way. The whole ceremony is still two lines:

src/main/java/com/example/example_mod/worldgen/CurvyChunkSource.java, end of getChunk()
Chunk chunk = RetroWorldGen.createChunk(this.world, blocks, chunkX, chunkZ);
chunk.populateHeightMap();
return chunk;

RetroWorldGen.createChunk has a companion, RetroWorldGen.setBlockInChunk(chunk, x, y, z, blockId, meta), for writing a single block at chunk-local coordinates (x and z are 0 to 15, y is 0 to 127) into a chunk you got from createChunk. Unlike writing into the raw byte[], it accepts any id, vanilla (< 256) or modded (>= 256), and it's generation-safe under both StationAPI and legacy worlds. Reach for it when you'd rather place blocks through one call than juggle the flat-array index, and especially when a generator needs to drop a modded block while the chunk is still being built rather than waiting for decorate.

A ChunkSource has more methods than that, loadChunk, isChunkLoaded, decorate, save, tick, canSave, getDebugInfo, but for a generator that builds the world from scratch every time, most are one-liners. loadChunk just calls getChunk, isChunkLoaded returns true, and Curvy's decorate is empty on purpose: bare hills are the whole story, so the equation is all there is. Decoration is where the next dimension lives.

Perlin terrain with ores, caves, and trees

The Noisy dimension is the teaching piece, a real noise-driven generator with everything Curvy left out. Its getChunk reads like a recipe, five named stages in order:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, getChunk()
for (int lx = 0; lx < 16; lx++) {
	for (int lz = 0; lz < 16; lz++) {
		int worldX = chunkX * 16 + lx;
		int worldZ = chunkZ * 16 + lz;
		fillColumn(blocks, lx, lz, heightOf(worldX, worldZ));
	}
}

generateOres(blocks, chunkX, chunkZ);
carveTunnels(blocks, chunkX, chunkZ);

Chunk chunk = RetroWorldGen.createChunk(this.world, blocks, chunkX, chunkZ);
chunk.populateHeightMap();
return chunk;

The height comes from Perlin noise, not an equation. Perlin is smooth, seamless, tileable randomness: sample it at (x, z) and nearby points return nearby values, so summed octaves (one broad, one fine) make natural hills instead of white-noise spikes. heightOf mixes two octaves and amplifies the result so the dimension reads as dramatic:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, heightOf()
/** Two octaves of Perlin summed and amplified, then offset to the base height. */
private int heightOf(int worldX, int worldZ) {
	double broadN = this.broad.sample(worldX / 80.0, worldZ / 80.0);
	double fineN = this.fine.sample(worldX / 20.0, worldZ / 20.0) * 0.25;
	double combined = broadN + fineN;
	return BASE_HEIGHT + (int) Math.round(combined * HEIGHT_AMPLITUDE);
}

fillColumn turns that height into a column the same way Curvy did, with one addition: any cell at or below sea level that would have been air gets filled with water, so low ground reads as lakes and oceans:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, fillColumn()
if (y == 0) {
	id = Block.BEDROCK.id;
} else if (y < surface - 4) {
	id = Block.STONE.id;
} else if (y < surface) {
	id = Block.DIRT.id;
} else if (y == surface) {
	id = surface < SEA_LEVEL ? Block.DIRT.id : Block.GRASS_BLOCK.id;
} else if (y <= SEA_LEVEL) {
	id = Block.WATER.id; // lakes/oceans fill anything below sea level
} else {
	id = 0;
}

Ores

generateOres drops vanilla-style veins into the stone. Each ore is a small descriptor, block, vein size, veins per chunk, and the y band it lives in, mirroring beta's distribution (coal high and common, diamond low and rare):

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, ORES table
private static final Ore[] ORES = {
	new Ore(Block.COAL_ORE, 16, 20, 0, 127),
	new Ore(Block.IRON_ORE, 8, 20, 0, 63),
	new Ore(Block.GOLD_ORE, 8, 2, 0, 31),
	new Ore(Block.REDSTONE_ORE, 7, 8, 0, 15),
	new Ore(Block.DIAMOND_ORE, 7, 1, 0, 15),
	new Ore(Block.LAPIS_ORE, 6, 1, 0, 31),
};

Each vein is a short random walk that replaces stone with the ore block, the same blobby shape beta produces. The descriptor table is the part you tune; the walk underneath stays the same.

Caves that wind

Caves are the interesting part, because the naive version looks wrong. A plain noise-threshold cave (sample above a threshold means air) makes blobby Swiss cheese. Real caves wind. So carveTunnels uses the vanilla trick: start a "worm" at a random point and step it forward along a slowly-turning heading, hollowing a sphere at each step. The heading wanders a little every step, and that wander is the whole source of the sinuous shape:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, carveWorm()
// Advance.
x += Math.cos(yaw) * Math.cos(pitch);
z += Math.sin(yaw) * Math.cos(pitch);
y += Math.sin(pitch);
// Wander the heading a little each step, the source of the winding shape.
yaw += (rand.nextDouble() - 0.5) * 0.35;
pitch += (rand.nextDouble() - 0.5) * 0.25;
pitch *= 0.85; // bias back toward horizontal so tunnels mostly snake sideways

Each worm is seeded per origin chunk and is allowed to reach a few chunks past its own border, so a tunnel that starts in one chunk carves its way into the neighbours and never stops dead at a chunk wall. That neighbourhood loop is why tunnels cross chunk seams seamlessly: every chunk re-runs the worms of every nearby origin and carves only the part of each sphere that falls inside its own column.

Decoration

Ores and caves edit the raw byte[] before the chunk is born. Trees, pools, and flowers can't: a tree pokes into the chunk next door, and that chunk may not exist yet. That is exactly what decorate is for. It runs after the raw terrain of this chunk and its neighbours exists, so it can read and write blocks through the world safely. Like vanilla, it offsets by 8 to stay clear of not-yet-generated edges, then places trees, an occasional pool, and flower patches, denser or sparser depending on which biome this chunk fell in:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, decorate()
int baseX = chunkX * 16 + 8;
int baseZ = chunkZ * 16 + 8;
Random rand = new Random(this.world.getSeed() ^ ((long) chunkX * 341873128712L + (long) chunkZ * 132897987541L));

boolean amplified = isAmplifiedBiome(baseX, baseZ);

// Funky trees: tall, varied skyroot-ish trees, denser in the lush biome.
int treeCount = amplified ? 2 : 6;
for (int i = 0; i < treeCount; i++) {
	int tx = baseX + rand.nextInt(16);
	int tz = baseZ + rand.nextInt(16);
	placeTree(tx, tz, rand);
}

Trees are built straight through world.setBlock, a trunk of logs and a blobby leaf crown, and placeTree bails early if the chosen spot isn't grass above sea level. That world-level placement is also how the next problem gets solved.

Placing modded blocks in worldgen

There's a hard wall in the chunk byte[]: a byte holds 0 to 255, and every RetroAPI block gets an id >= 256 (the id story from Chapter 4). Vanilla grass, dirt, stone, ore, and water all fit in a byte, which is why the column fill and the ore veins write the array directly. A modded block cannot. Write its id into the byte array and the high bits are simply lost.

There are two ways around the wall. The first, if you only need the block at the moment the chunk is built, is RetroWorldGen.setBlockInChunk(chunk, x, y, z, blockId, meta) from the section above: it takes any id, vanilla or modded, and writes it straight into the chunk you got from createChunk, so a generator can place a modded block in getChunk without ever touching the lossy byte[]. The second, used when the placement needs information that only exists once neighbouring chunks are generated, is to place modded blocks after the chunk exists, in decorate, through world.setBlock. That path goes through RetroAPI's extended block storage (the sidecar that overlays real ids >= 256 on top of the vanilla array), so the block survives the round trip to disk and back. The showcase's pink flower is the example. It's a cross block, the X-shaped quad shape every beta flower and sapling uses, from Chapter 4's block toolkit:

src/main/java/com/example/example_mod/ExampleFlowerBlock.java
public class ExampleFlowerBlock extends Block {

	public ExampleFlowerBlock(int id) {
		super(id, Material.PLANT);
		this.setBoundingBox(0.3F, 0.0F, 0.3F, 0.7F, 0.6F, 0.7F);
	}

	@Override
	public int getRenderType() {
		return 1; // the vanilla cross render: 3D X in the world, flat sprite in the slot
	}
}

Render type 1 is the trick that makes its inventory icon a flat sprite like a dandelion while the world block is a 3D cross; RetroAPI leaves non-custom render types alone, so this is pure vanilla behaviour. The worldgen side is the flower patch, which only places on bare grass above sea level and reaches the modded block through setBlock:

src/main/java/com/example/example_mod/worldgen/NoisyChunkSource.java, placeFlowerPatch()
int fy = this.world.getTopY(fx, fz);
if (fy > SEA_LEVEL
		&& this.world.getBlockId(fx, fy, fz) == 0
		&& this.world.getBlockId(fx, fy - 1, fz) == Block.GRASS_BLOCK.id) {
	this.world.setBlock(fx, fy, fz, ExampleMod.PINK_FLOWER.id);
}

ExampleMod.PINK_FLOWER.id is the runtime id >= 256. Because it's going through setBlock and not the byte array, it lands correctly and persists, and because the flower needs a cross-chunk top-Y lookup, decorate is the right home for it. The rule to carry away: never write a modded id into the raw byte[], route it through RetroWorldGen.setBlockInChunk at build time or through world.setBlock in decorate.

The flower's sprite is a single cross-shaped texture, saved at the path below:

src/main/resources/assets/example_mod/textures/block/pink_flower.png

pink_flower
pink_flower.png
download

Biomes

A ChunkSource shapes the land; a BiomeSource answers "what biome is at (x, z)?" for grass and sky tint, temperature, and mob spawning. The dimension picks both. Curvy uses the simplest possible biome source, one biome everywhere:

src/main/java/com/example/example_mod/worldgen/CurvyDimension.java, initBiomeSource()
@Override
protected void initBiomeSource() {
	// One gentle biome: friendly animals only (wired in ExampleMod.registerSpawns).
	this.biomeSource = new SingleBiomeSource(ExampleMod.CURVY_BIOME, 0.7);
}

The biomes themselves are built with RetroAPI's BiomeBuilder, a fluent builder that sets tint, weather, and per-biome spawns. The Curvy biome is one gentle green region full of vanilla farm animals; the Noisy dimension has two, an orange "amplified" biome and a green "lush" one:

src/main/java/com/example/example_mod/ExampleMod.java, in init()
CURVY_BIOME = BiomeBuilder.start("Curvy Hills")
	.grassAndLeavesColor(0x88DD66)
	.passiveEntity(CowEntity.class, 8)
	.passiveEntity(PigEntity.class, 8)
	.passiveEntity(SheepEntity.class, 8)
	.passiveEntity(ChickenEntity.class, 6)
	.build();
NOISY_AMPLIFIED_BIOME = BiomeBuilder.start("Amplified Wastes")
	.grassAndLeavesColor(0xFF8A3D) // funky orange grass
	.build();
NOISY_LUSH_BIOME = BiomeBuilder.start("Lush Hollows")
	.grassAndLeavesColor(0x5Fae3a)
	.build();

BiomeBuilder.start(name) opens the builder, chained calls like grassAndLeavesColor, precipitation, snow, and passiveEntity configure it, and build() returns a plain vanilla Biome the dimension hands back from its biome source. No global biome registration is needed; the dimension owns its biomes.

The Noisy dimension picks between its two biomes by noise. NoisyBiomeSource samples a wide, low-frequency Perlin field and thresholds it at zero, so the two biomes form large contiguous regions with a soft boundary:

src/main/java/com/example/example_mod/worldgen/NoisyBiomeSource.java, pick()
private Biome pick(int x, int z) {
	return this.biomeNoise.sample(x / 220.0, z / 220.0) > 0.0 ? this.amplified : this.lush;
}

NoisyDimension is the Dimension subclass that wires the noise chunk generator to the two-biome source, the same shape as the Curvy dimension but pointing at the noisy pieces:

src/main/java/com/example/example_mod/worldgen/NoisyDimension.java
public class NoisyDimension extends Dimension {

	public NoisyDimension(int serialId) {
		this.id = serialId;
	}

	@Override
	protected void initBiomeSource() {
		this.biomeSource = new NoisyBiomeSource(this.world.getSeed(),
			ExampleMod.NOISY_AMPLIFIED_BIOME, ExampleMod.NOISY_LUSH_BIOME);
	}

	@Override
	public ChunkSource createChunkGenerator() {
		return new NoisyChunkSource(this.world);
	}

	@Override
	public boolean hasWorldSpawn() {
		return true;
	}

	@Override
	public boolean isValidSpawnPoint(int x, int z) {
		return true;
	}
}

The biome source and the chunk source must sample the same noise. The grass colour at a spot is decided here (by which biome pick returns), but the tree and flower density there is decided in the chunk source's decorate by its own isAmplifiedBiome. If the two disagreed, you'd get orange grass with lush-biome foliage. They don't, because both build an OctavePerlinNoiseSampler from seed + 3 with two octaves and sample at / 220.0. Same seed, same frequency, same answer. When you split a dimension into regions, route both decisions through one shared noise.

Editing existing biomes

Authoring a brand-new biome is one thing; reaching into a biome that already exists, vanilla's or another mod's, is another, and it's where mods break each other. Beta has a single global set of biome objects (Biome.PLAINS, Biome.FOREST, and so on) shared by every dimension and every mod, so clobbering a spawn list is how you stomp on everyone else. RetroAPI's RetroBiomes is built around one rule: additive, never destructive. Every add method appends to a list and leaves every other entry in place, and addSpawn de-dupes by class, so registering the same mob twice updates its weight instead of stacking a duplicate:

src/main/java/com/example/example_mod/ExampleMod.java, in init()
// RetroBiomes.addPassiveSpawn is ADDITIVE and de-dupes, so it never clobbers
// another mod's spawns; here it drops Aerbunnies into both Noisy biomes.
RetroBiomes.addPassiveSpawn(NOISY_AMPLIFIED_BIOME, AerbunnyEntity.class, 4);
RetroBiomes.addPassiveSpawn(NOISY_LUSH_BIOME, AerbunnyEntity.class, 4);

The same family covers monsters (addMonsterSpawn), water creatures (addWaterSpawn), a sweep over many biomes at once (addSpawnToAll), and tint recolours (setGrassColor, setFoliageColor). There are destructive methods, setSpawn and clearSpawns, but they exist for the rare case where a biome is genuinely yours to define, and the docs say so plainly. Reach for them only when you mean to take a biome over. The Chapter 7 Aerbunny is what's being added here, dropped into both Noisy biomes so it has somewhere to live.

Spawn caps for modded mobs

Adding a mob to a biome's spawn list is only half of making it spawn naturally, because beta also caps how many of each category can exist. Its NaturalSpawner counts animals, monsters, and water creatures separately and stops spawning a category once it's full. The catch is how it counts: an instanceof test against a vanilla base class (AnimalEntity for the animal cap, and so on). A custom mob that extends LivingEntity directly, the common case, is an instance of none of those, so vanilla never counts it and it would spawn without limit, swarming the world.

RetroSpawnGroups closes that gap. It records which category each modded class belongs to, and a mixin on the counting path adds the modded count back in, so the cap applies to every mob, vanilla or modded, with no per-mob code:

src/main/java/com/periut/retroapi/entity/RetroSpawnGroups.java, assign()
/** Records that {@code entityClass} spawns under {@code group} (so the cap counts it). */
public static void assign(Class<? extends LivingEntity> entityClass, SpawnGroup group) {
	GROUPS.put(entityClass, group);
}

You almost never call assign yourself. It runs automatically the moment a mob is added to a biome, whether through BiomeBuilder.passiveEntity or RetroBiomes.addPassiveSpawn, both of which call it under the hood with the right vanilla group. So wiring the Aerbunny into the Noisy biomes above did two jobs at once: it gave the bunny somewhere to spawn, and it taught the animal cap to count it. The bunny lives only in the noisy dimension, exactly because that's the only place it was added.

Portals between dimensions

Two custom dimensions need two ways in, and rather than write two portal classes the showcase writes one configurable DimensionPortalBlock and registers it twice. It's the same CustomPortal pattern from Chapter 10, walk into it, a timer fills, your dimension flips, but the destination and the block to place at the far end are passed in:

src/main/java/com/example/example_mod/worldgen/DimensionPortalBlock.java
public DimensionPortalBlock(int id, Supplier<NamespacedIdentifier> destination, Supplier<Integer> portalBlockId) {
	super(id, Material.GLASS);
	this.destination = destination;
	this.portalBlockId = portalBlockId;
}

@Override
public NamespacedIdentifier getDimension(PlayerEntity player) {
	return this.destination.get();
}

The destination is a Supplier, not a value, for a real ordering reason: blocks register before dimensions, so the dimension registration object doesn't exist yet when the portal block is constructed. Wrapping it in a supplier defers the lookup to collision time, when everything exists. The registration ties it together, two instances of one class, each pointing at its own dimension and its own portal sprite:

src/main/java/com/example/example_mod/ExampleMod.java, in init()
CURVY_PORTAL = RetroBlockAccess.of(new DimensionPortalBlock(RetroBlockAccess.allocateId(),
		() -> CURVY_DIMENSION.getId(), () -> CURVY_PORTAL.id))
	.sounds(Block.GLASS_SOUND_GROUP)
	.texture(id("green_glass"))
	.register(id("curvy_portal"));

The Noisy dimension's portal is the second instance, identical but for its destination and sprite: the Curvy portal wears green_glass and the Noisy portal wears orange_glass, so the two doorways read apart at a glance.

green_glass
green_glass.png
download
orange_glass
orange_glass.png
download

src/main/resources/assets/example_mod/textures/block/green_glass.png, orange_glass.png

The return trip is the part Chapter 10 hand-waved. RetroAPI flips the travel direction for a player already in the dimension, but the portal also has to be there when you arrive. That's the second supplier, portalBlockId, handed to a custom travel agent. getTravelAgent returns a GroundPortalForcer seeded with the portal's own id, and that forcer either reuses a portal from an earlier trip or drops a fresh one on the surface:

src/main/java/com/example/example_mod/worldgen/GroundPortalForcer.java, moveToPortal() tail
int y = world.getTopY(ex, ez);
world.setBlock(ex, y, ez, this.portalBlockId);
entity.setPositionAndAngles(ex + 0.5, y + 1.5, ez + 0.5, entity.yaw, entity.pitch);

So the round trip closes itself: you walk in through a portal block, RetroAPI flips your direction and asks the forcer where to land, and the forcer plants an identical portal block on the ground at the destination. Step into that one and the same flip sends you home. The whole loop is forced by that one block id threaded through both suppliers.

What you have now

You can grow a world from a single equation, then trade the equation for Perlin noise and add ores, winding caves, lakes, trees, and biomes. You know the one rule that keeps modded blocks out of the chunk byte array, the one rule that keeps biome edits from breaking other mods, and the one registry that keeps your mobs honest about the spawn cap. And you can walk into any of it through a portal that builds its own return trip. That is a complete, self-contained land.

The next chapter turns from worlds back to the smallest unit of all, the item, and gives it a memory: typed data components that ride along on an ItemStack, drive tooltips, and even change an icon. On to Chapter 21, Item Memory.