Worldgen
Four separate jobs that get confused with each other: putting things into worlds that already exist, describing a biome, building a whole chunk generator, and connecting worlds together.
Start at the top of this page if you want your ore to appear in the normal overworld, that is the common case and it needs no mixins and no generator of your own. The later sections build up to a dimension with its own terrain.
| If you want to… | Go to |
|---|---|
| put ore, boulders or structures into existing worlds | Features |
| describe a new biome (colors, weather, spawns) | Biomes |
| change spawns or colors of a biome that already exists | Editing existing biomes |
| build terrain from scratch | Chunk generators |
| register the world itself, and a way in | Dimensions & portals |
Adding features to worlds that already exist
Everything above builds a new world. The far more common wish is smaller: "I want my ore to generate in the normal overworld." In beta that meant mixin-ing vanilla's chunk generator, because what generates is a hardcoded list inside decorate, with ore veins written as new OreFeature(id, size) and their height bands as literals. There is no rarity knob, no metadata, and nothing but stone can be replaced.
RetroAPI hooks decoration once, for every dimension and every generator (including custom ones), and runs whatever mods registered, after vanilla's own decoration for that chunk:
// A plain ore: 8 veins of 6 blocks per chunk, between y=4 and y=32.
RetroFeatures.ore(RUBY_ORE).size(6).count(8).heightRange(4, 32).register();
// A rare, large stone variant carrying state index 2, replacing vanilla stone.
RetroFeatures.ore(STONE_TYPES).meta(2).size(48).count(3).rarity(3)
.heightRange(0, 96).replace(Block.STONE).register();
// An ore that generates INSIDE your own stone types, not just vanilla stone.
RetroFeatures.ore(RUBY_ORE).size(5).count(6).heightRange(0, 48)
.replace(Block.STONE, MARBLE, SLATE).register();
// Anything else at all.
RetroFeatures.custom((world, random, x, y, z) -> {
int surface = RetroFeatures.surfaceY(world, x, z);
RetroFeatures.setBlock(world, x, surface, z, ExampleMod.MARKER.id, 0);
return true;
}).count(1).rarity(64).register();| Call | What it controls |
|---|---|
.size(n) | blocks per vein (vanilla ores use 4–33) |
.count(n) | attempts per chunk |
.heightRange(min, max) | inclusive Y band each attempt is rolled in |
.rarity(n) | places in one chunk out of n, the knob vanilla simply lacks |
.meta(n) | the metadata / flattened state each block is placed with |
.replace(blocks…) | what the vein may carve into (default: vanilla stone) |
.dimensions(ids…) / .dimension(id) / .anyDimension() | where it applies (default: the overworld) |
Generation stays deterministic: the decoration random is seeded from the world seed and chunk coordinates exactly the way vanilla seeds its own, so a seed still gives the same world. A feature that throws is logged and skipped rather than taking the chunk, and the world, down with it.
Custom generators get the same treatment for free, since the hook sits on the chunk cache rather than on any one generator. If your generator does its own population outside that path, call RetroFeatures.decorate(world, chunkX, chunkZ) yourself.
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:
@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:
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:
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:
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 color 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:
// 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 Entities 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:
/** 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.
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:
// 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:
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:
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:
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:
/** 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:
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):
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:
// 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 sidewaysEach 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 neighbors and never stops dead at a chunk wall. That neighborhood 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 neighbors 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:
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 Blocks). 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 neighboring 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 Blocks's block toolkit:
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 behavior. The worldgen side is the flower patch, which only places on bare grass above sea level and reaches the modded block through setBlock:
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
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 Dimensions & portals, walk into it, a timer fills, your dimension flips, but the destination and the block to place at the far end are passed in:
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:
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.
src/main/resources/assets/example_mod/textures/block/green_glass.png, orange_glass.png
The return trip is the part Dimensions & portals 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:
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.


