Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Biomes

Verified against Minecraft 26.2 · Part XII · A point in the world gets a biome: six numbers quantised to integers, a nearest-neighbour search in seven dimensions, and the two different answers the game keeps for the same block.

Walk out of a desert into a jungle and watch the ground. The grass changes colour at one line. The fog and the sky change at a different line, a couple of blocks away. Neither is a bug and neither is a rendering artefact: the game genuinely stores one biome per four-by-four-by-four volume and then answers “which biome is this block in?” two different ways, one jittered and one not, and different systems ask different questions. The surprise is which side each thing is on — grass colour, mob spawning and whether water freezes are all on the jittered side, and the sky is not.

A biome is a label in the chunk at quarter resolution plus a bundle of consequences hanging off that label. In 26.2 the bundle has been hollowed out: Biome itself holds five things, and most of what a player would call “the biome” now lives in the environment-attribute stack, where the biome is one layer among several rather than the owner (environment attributes and timelines).

The cast

classwhat it decideswhen
BiomeSourcewhich biome a quart cell gets. Four implementations, and BiomeSource.possibleBiomes is the memoised pre-filter everything else leans onChunkStatus.BIOMES, on a worldgen worker
Climate.Samplerthe six climate numbers at a point. Filling a chunk uses NoiseChunk.cachedClimateSampler, the chunk-wrapped copy; RandomState.sampler is the flattened, cacheless one everything outside a chunk asks (density functions)per quart cell
Climate.ParameterListthe search space: one Climate.ParameterPoint per biome, indexed by a Climate.RTreebuilt once per world
OverworldBiomeBuilderthe overworld’s parameter table, in Java — temperature, humidity, erosion and continentalness bands over six tables of biome keysbuild time
LevelChunkSectionwhere the answer lives: a second PalettedContainer keyed by biome holder, two bits per axiswritten once, saved, shipped
BiomeManagerthe jitter — which biome this block gets, as opposed to which cell it is inevery gameplay read
Biomefive things: climate settings, an EnvironmentAttributeMap, BiomeSpecialEffects, generation settings and mob settings
EnvironmentAttributeMapthe biome’s contribution to sky, fog, music and the gameplay switches — as modifiers, not values. Twenty gameplay attributes exist; the sixty-six vanilla biome files touch three of them between them, and fifty-one touch noneper attribute, per read

The trace: a chunk’s biomes

sequenceDiagram
    participant CST as ChunkStatusTasks
    participant NBC as NoiseBasedChunkGenerator
    participant CA as ChunkAccess
    participant LCS as LevelChunkSection
    participant MNBS as MultiNoiseBiomeSource
    participant ClimS as Climate.Sampler
    participant CPList as Climate.ParameterList
    participant CRT as Climate.RTree

    CST->>NBC: createBiomes — ChunkStatus.BIOMES, which NOISE and SURFACE both require
    NBC->>NBC: fork to init_biomes, wrap the resolver in Blender and BelowZeroRetrogen
    NBC->>CA: fillBiomesFromNoise, with the chunk's cached climate sampler
    CA->>LCS: fillBiomesFromNoise — rebuild the container, 64 cells per section
    loop per quart cell
        LCS->>MNBS: getNoiseBiome — quart coordinates and the sampler
        MNBS->>ClimS: sample — six functions, each multiplied by 10,000 and truncated
        ClimS-->>MNBS: a Climate.TargetPoint of six longs
        MNBS->>CPList: findValue — the parameter list owns the search
        CPList->>CRT: findValueIndex — nearest neighbour over seven dimensions
        CRT-->>LCS: a biome holder, into the palette
    end
    Note over LCS: saved under "biomes", shipped inside the chunk payload

The two wrappers in the second arrow only do anything beside chunks an older version generated — blending at the old-chunk border is where they are explained.

Biomes are decided before terrain, and not for it. ChunkStatus.BIOMES precedes ChunkStatus.NOISE, and the two do not depend on each other at all: the noise fill never reads a biome. What makes a jungle and its terrain agree is that both were computed from the same noise router — RandomState builds the climate sampler out of the depth, continents, erosion and ridges functions, the very ones that shape the land. Neither was consulted about the other. The biome does not touch a block until ChunkStatus.SURFACE (terrain).

Only the noise generator does the above. FlatLevelSource and DebugLevelSource inherit the base implementation and use the level’s uncached sampler directly. The End is a NoiseBasedChunkGenerator like the overworld and the nether — just with TheEndBiomeSource in front of it, which does not do a climate search at all: it thresholds a single erosion sample outside a fixed central radius.

The search, and the axis that is not sampled

Climate.quantizeCoord multiplies each of the six climate values by ten thousand and truncates, so the entire search is integer arithmetic. The target is a Climate.TargetPoint of six longs; each biome declares a Climate.ParameterPoint of six Climate.Parameter intervals; and Climate.ParameterList.findValue walks a Climate.RTree — six children per node — minimising the sum of squared distances from the target to each interval.

Except the count is seven, not six. Climate.PARAMETER_COUNT is 7 and Climate.RTree.create refuses a point that does not supply seven, because each Climate.ParameterPoint carries a scalar offset alongside its six intervals, and Climate.TargetPoint.toParameterArray appends a literal zero as the seventh coordinate of every query. So the seventh term of the metric is always that biome’s offset squared: a fixed penalty added to its score. It is a “make this biome harder to win” dial, not anything sampled from the world.

The tree also remembers. Climate.RTree keeps the winning leaf in a ThreadLocal and seeds the next search with it as the initial candidate. Adjacent quart cells almost always resolve to the same biome, so the walk usually prunes immediately — which is what makes filling sixty-four cells a section cheap. The tree is therefore stateful per thread, though never incorrect: the remembered leaf is only a starting bound.

One thing the parameter table does not have is a separate underground system. OverworldBiomeBuilder.addUndergroundBiomes and OverworldBiomeBuilder.addBottomBiome place dripstone caves, lush caves, sulfur caves and the deep dark into the same seven-dimensional table by their depth band. A cave biome is an ordinary entry that happens to win only below the surface.

The two borders

The label goes into the section’s biome palette — two bits per axis, so sixty-four biome cells per section — is written to NBT under biomes (chunk storage) and is shipped to the client inside the chunk payload. From then on it is stored, not computed, which is why /fillbiome can exist at all and why ClientboundChunksBiomesPacket exists to tell the client about it.

And then two different readers ask for it two different ways.

the jittered readthe exact read
entry pointLevelReader.getBiomeBiomeManager.getBiomeBiomeManager.getNoiseBiomeAtPosition, and on the client BiomeManager.getNoiseBiomeAtQuart
what it doesoffsets by two, takes the eight surrounding quart corners, and picks the one minimising BiomeManager.getFiddledDistance — a seeded hash worth up to ±0.45 of a cell per axisfloors to the quart cell and reads the palette
who uses itfreezing and precipitation, mob spawning, commands — and block tint: grass, foliage and water colour, through ClientLevel.calculateBlockTintthe environment-attribute stack, and nothing else
what it looks likethe ragged borderthe straight one

Block tint is on the jittered side, which is the half of this that surprises people: grass colour follows exactly the same ragged line as whether snow falls. What softens the colour boundary in game is not the biome lookup but a box blur on top of it — ClientLevel.calculateBlockTint averages the result over the columns named by the biome blend radius option and caches that in a BlockTintCache. Fog and sky are the ones on the other border.

The client’s exact read is the more expensive of the two, and unconditionally so: EnvironmentAttributeProbe.tick runs a GaussianSampler over the neighbourhood every tick, accumulating whole EnvironmentAttributeMaps into a SpatialAttributeInterpolator. Whether an attribute is actually interpolated is tested later, when the layer is applied, and one that fails the test falls back to a single unfuzzed lookup. The server never interpolates at all — it passes no interpolator.

What a biome still owns

Five things, and none of them stops being read once the chunk is generated — NaturalSpawner asks the mob settings every tick and a bone-mealed grass block asks the generation settings.

Biome.climateSettings is precipitation, a base temperature, a Biome.TemperatureModifier and downfall. Temperature is the interesting one: Biome.getHeightAdjustedTemperature samples noise per block high above sea level — which is why snow lines are ragged rather than flat — and Biome keeps a fixed-size per-thread cache in front of it that evicts rather than grows. Most of the public surface is the questions rather than the number: Biome.warmEnoughToRain, Biome.coldEnoughToSnow, Biome.shouldFreeze, Biome.shouldSnow. Biome.getBaseTemperature is the raw, uncached, unadjusted escape hatch.

BiomeSpecialEffects is, in 26.2, only block tint — five fields, all of them colours or a grass-colour modifier, and only the water colour is mandatory. Fog, sky, clouds, ambient sound, music and particles have all left it for the attribute stack (lightmap, fog and sky). And when the four optional ones are silent, the tint does not come from the biome at all: grass and foliage colour are a lookup into the colormap images by temperature and downfall, through GrassColor, FoliageColor and DryFoliageColor. “The biome’s grass colour” is usually just the two climate numbers that index a texture.

Biome.getAttributes returns the EnvironmentAttributeMap, whose entries are AttributeModifiers rather than values — so a biome may override the layer below it or merely modify it, which is how a swamp thickens water fog without naming a distance. One restriction lands here specifically: EnvironmentAttributeMap.CODEC_ONLY_POSITIONAL means a biome may not set a non-positional attribute at all.

BiomeGenerationSettings is a set of carvers and one set of placed features per decoration step, read by features and placement; BiomeGenerationSettings.getBoneMealFeatures is its only reader outside worldgen, and its one caller is GrassBlock. MobSpawnSettings is the weighted spawn lists NaturalSpawner reads (entity lifecycle) — and its entry constructor silently rewrites any miscellaneous-category entity type to pig.

Questions players ask

Why do I always spawn near the origin? Because the chunk is chosen by a climate search. Climate.SpawnFinder and Climate.findSpawnPosition look for the point whose climate best matches the noise settings’ spawn target, in two spiral passes out to a maximum radius of 2,048 blocks, with depth pinned to zero and the fitness deliberately biased toward the origin so that a tie lands near 0,0. MinecraftServer then keeps only that answer’s chunk and does a terrain search inside it: an eleven-by-eleven chunk spiral of PlayerSpawnFinder.getSpawnPosInChunk, over a first guess taken from ChunkGenerator.getSpawnHeight or, failing that, the WORLD_SURFACE heightmap. A dimension whose settings name no spawn target skips the climate half and starts that spiral at the origin chunk.

Why does /locate biome find biomes in chunks I have never visited? Because it asks the generator, not the world. BiomeSource.findClosestBiome3d spirals through BiomeSource.getNoiseBiome with the live sampler and never reads a palette — so it finds biomes in ungenerated chunks and will never find one placed by /fillbiome. It pre-filters against BiomeSource.possibleBiomes, so asking for an impossible biome fails instantly rather than after a spiral out to six thousand four hundred blocks.

Can a data pack change the overworld’s biome layout? Not the vanilla preset. OverworldBiomeBuilder is hardcoded, and the data-pack element that would carry a parameter list serialises to nothing but a preset name — there are two presets. A pack can supply its own parameter list for a multi-noise source; it cannot edit the one that ships.

What does the client actually have? A hollow Biome. Biome.NETWORK_CODEC sends the climate settings, the syncable attributes and the effects, and substitutes empty generation and mob settings — features, carvers and spawn lists never cross the wire. And when a client asks for a biome in a chunk it does not have, it gets plains.

Does the biome decide which mobs spawn? Not on its own, and not first. ChunkGenerator.getMobsAt consults Structure.spawnOverrides before Biome.getMobSettings (structure placement), and nether fortresses are special-cased earlier still, inside NaturalSpawner.

Why does adding one biome change the whole dimension? Because EnvironmentAttributeSystem builds one positional layer per attribute that any biome in the registry mentions, at level construction. One new biome naming one new attribute adds a layer every position in the dimension then carries.

Where to look

Biome · Biome.getAttributes · BiomeSpecialEffects · BiomeSource.getNoiseBiome · BiomeSource.possibleBiomes · MultiNoiseBiomeSource · TheEndBiomeSource · Climate.Sampler.sample · Climate.quantizeCoord · Climate.ParameterList.findValue · Climate.RTree.search · Climate.PARAMETER_COUNT · Climate.findSpawnPosition · OverworldBiomeBuilder.addBiomes · OverworldBiomeBuilder.addUndergroundBiomes · LevelChunkSection.fillBiomesFromNoise · BiomeManager.getBiome · BiomeManager.getFiddledDistance · BiomeManager.getNoiseBiomeAtPosition · BiomeGenerationSettings · MobSpawnSettings · BiomeColors · EnvironmentAttributeProbe


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.