Skip to content
Edit on GitHub

Add a procedural biome

import BiomeClimateMatrix from ‘../../../components/BiomeClimateMatrix.astro’;

verifiedAgainst 625483a · verifiedOn 2026-09-10.

Terrain in Voxamine is classified through a two-dimensional continuous climate coordinate plane: Temperature ($T \in [-1.0, +1.0]$) and Moisture ($M \in [-1.0, +1.0]$). Every voxel column is sampled deterministically from the world seed, and resolved against a sequential chain of climate envelopes in BiomeCatalog.ClassificationOrder.

Adding a biome requires understanding how carve-outs work: specialized biomes steal territory from wider parent biomes. If their priority is inverted, the parent biome swallows the specialized biome and it will never generate in the world.

This guide walks through creating and wiring a brand-new procedural biome end to end, then distills the pattern into a checklist.

The Volcanic Basalt Shelf (BiomeType.BasaltShelf) — a severe, high-temperature, low-moisture geological formation featuring exposed volcanic stone plateaus:

  • Climate Window: High temperature ($T \in [0.7, 1.0]$), extreme aridity ($M \in [-1.0, -0.4]$).
  • Territory Origin: Carved out of the hottest, driest corner of Badlands and Desert.
  • Surface Material: BiomeSurfaceBlock.Stone (dark volcanic basalt rock instead of sand or grass).
  • Topography: Elevated plateau formation ($1.4\times$ height amplitude multiplier).
  • Vegetation: Barren ($0.0\times$ tree density multiplier).
  • Weather Response: Arid susceptibility ($0.04$), never freezes.
  • Ambience Bed: Dry gusting wind with elevated nocturnal presence.

Terrain classification connects the pure generation math with runtime environment profiles:

flowchart TD
  SEED["World Seed + Salts"] --> NOISE["DeterministicValueNoise.Sample2D\nTemperature & Moisture in [-1, +1]"]
  NOISE --> CLASSIFIER["BiomeClassifier.Classify()\nevaluates BiomeCatalog.ClassificationOrder"]
  
  subgraph Data ["VoxelSandbox.Data"]
    CATALOG["BiomeCatalog\nauthored windows & precedence order"]
    PALETTE["GenerationBlockPalette\nsurface block & color tint resolution"]
  end

  CATALOG --> CLASSIFIER
  CLASSIFIER --> BURST["TerrainColumnMath & TerrainGenerationJob\nBurst-compiled sequential biome index (0..9)"]
  PALETTE --> BURST
  BURST --> VOXELS["IVoxelBlockWorld\nfinal block placement & meshing"]

  CLASSIFIER --> WEATHER["BiomeWeatherProfile\nprecipitation susceptibility & freezing"]
  CLASSIFIER --> AUDIO["BiomeAmbienceProfile\nspatial audio looping beds"]

  classDef core fill:#1e1e24,stroke:#e58a63,stroke-width:1px,color:#f8f9fa;
  classDef data fill:#0c4f48,stroke:#6fd8c6,stroke-width:1px,color:#f8f9fa;
  class SEED,NOISE,CLASSIFIER,BURST,VOXELS core;
  class CATALOG,PALETTE,WEATHER,AUDIO data;
  1. Carve-out precedence order: A child envelope that steals territory from a parent envelope must be listed before the parent in BiomeCatalog.ClassificationOrder. Plains spans $[-1.0, +1.0]$ on both axes and is checked last as the universal fallback.
  2. Deterministic noise range: Temperature and moisture noise samples are bounded in $[-1.0, +1.0]$. A window outside this range will never match.
  3. Sequential indices vs. Flags enum: In managed C#, BiomeType is a [Flags] bitmask enum (1 << 0, 1 << 1, etc.) so it can be combined for tree species masks. In Burst jobs and sediment calculations, biomes use a sequential compact index (0..N). Never cast (int)BiomeType to get a job index.

Read these files at pinned commit 625483a before authoring:

# File Symbol / What to extract
1 Assets/_Game/Scripts/Data/Generation/BiomeType.cs BiomeType flags values and BiomeSurfaceBlock enum entries.
2 Assets/_Game/Scripts/Data/Generation/BiomeDefinition.cs Matches(temp, moisture) bounding checks and Validate() clamping.
3 Assets/_Game/Scripts/Data/Generation/BiomeCatalog.cs ClassificationOrder array order and default parameter construction.
4 Assets/_Game/Scripts/World/Generation/TerrainColumnMath.cs ClassifyBiomeIndex Burst implementation and priority order.
5 Assets/_Game/Scripts/World/Generation/TerrainGenerator.cs GetSedimentBiomeIndex mapping from BiomeType flag to sequential index.
6 Assets/_Game/Scripts/World/Environment/BiomeWeatherProfile.cs Atmospheric weather response table.
7 Assets/_Game/Scripts/Player/BiomeAmbienceProfile.cs Audio ambience bed assignments and diurnal gains.
8 Assets/_Game/Tests/EditMode/World/BiomeClassifierTests.cs Representative area classification and carve-out non-regression tests.
Decision Choice Why
Enum Flag BasaltShelf = 1 << 9 ($512$) Next unallocated bit in BiomeType flags.
Temperature Range $[+0.7, +1.0]$ Torrid volcanic thermal band.
Moisture Range $[-1.0, -0.4]$ Extreme arid moisture band.
Parent Biomes Badlands ($[0.5, 1.0] \times [-1.0, -0.6]$) and Desert ($[0.3, 1.0] \times [-1.0, -0.1]$) The basalt shelf carves out the ultra-hot corner from both.
Evaluation Order First (index 0 in ClassificationOrder) Must be checked before Badlands and Desert, otherwise Badlands matches first.
Surface Block BiomeSurfaceBlock.Stone Solid rock ground without topsoil.
Height Multiplier $1.4\times$ Steep stepped volcanic plateau topography.
Tree Density $0.0\times$ No organic tree growth on barren basalt crust.

Step 1: Register the Enum Flag in BiomeType.cs

Section titled “Step 1: Register the Enum Flag in BiomeType.cs”

Open Assets/_Game/Scripts/Data/Generation/BiomeType.cs and allocate bit 9:

[Flags]
public enum BiomeType
{
None = 0,
Plains = 1 << 0,
Forest = 1 << 1,
Desert = 1 << 2,
Taiga = 1 << 3,
Savanna = 1 << 4,
Badlands = 1 << 5,
Mountains = 1 << 6,
Jungle = 1 << 7,
Tundra = 1 << 8,
BasaltShelf = 1 << 9
}

Step 2: Configure the Envelope & Precedence in BiomeCatalog.cs

Section titled “Step 2: Configure the Envelope & Precedence in BiomeCatalog.cs”

Open Assets/_Game/Scripts/Data/Generation/BiomeCatalog.cs.

  1. Add the serialized definition field and getter:
[SerializeField]
private BiomeDefinition basaltShelf = BiomeDefinition.CreateDefault(
BiomeType.BasaltShelf, BiomeSurfaceBlock.Stone, heightAmplitudeMultiplier: 1.4f, treeDensityMultiplier: 0f,
minTemperature: 0.7f, maxTemperature: 1f, minMoisture: -1f, maxMoisture: -0.4f);
public BiomeDefinition BasaltShelf => basaltShelf;
  1. Crucial: Insert basaltShelf at the very start of ClassificationOrder before badlands and desert:
/// <summary>
/// Priority order for classification; Plains' full-range window is the guaranteed
/// fallback. BasaltShelf, Badlands, and Mountains are narrower windows carved out of
/// Desert's and Taiga's windows — each must be checked before the window(s) it steals
/// territory from.
/// </summary>
public IReadOnlyList<BiomeDefinition> ClassificationOrder => new[]
{
basaltShelf, tundra, badlands, desert, mountains, taiga, jungle, savanna, forest, plains
};
  1. Update Validate() to ensure the instance is hydrated and clamped:
public void Validate()
{
basaltShelf ??= BiomeDefinition.CreateDefault(BiomeType.BasaltShelf, BiomeSurfaceBlock.Stone, 1.4f, 0f, 0.7f, 1f, -1f, -0.4f);
tundra ??= BiomeDefinition.CreateDefault(BiomeType.Tundra, BiomeSurfaceBlock.Snow, 0.53f, 0f, -1f, -0.7f, -1f, -0.3f);
// ...
basaltShelf.Validate();
tundra.Validate();
// ...
}

Step 3: Keep the Burst Classification in Parity in TerrainColumnMath.cs

Section titled “Step 3: Keep the Burst Classification in Parity in TerrainColumnMath.cs”

Burst jobs cannot allocate or iterate managed collections. In Assets/_Game/Scripts/World/Generation/TerrainColumnMath.cs, the classification order is unrolled in code.

Assign sequential index 9 to BasaltShelf and check it first:

/// <summary>
/// Biomes: index 0=Badlands, 1=Desert, 2=Mountains, 3=Taiga, 4=Savanna, 5=Forest,
/// 6=Tundra, 7=Jungle, 8=Plains (fallback), 9=BasaltShelf.
/// BasaltShelf is checked first because it carves out of Badlands and Desert.
/// </summary>
internal static int ClassifyBiomeIndex(int worldX, int worldZ, in TerrainGenerationParameters parameters)
{
SampleClimate(worldX, worldZ, parameters, out float temperature, out float moisture);
// Carve-out check 1: Basalt Shelf
if (MatchesBiomeWindow(temperature, moisture, parameters.BasaltShelfMinTemperature, parameters.BasaltShelfMaxTemperature, parameters.BasaltShelfMinMoisture, parameters.BasaltShelfMaxMoisture))
{
return 9;
}
if (MatchesBiomeWindow(temperature, moisture, parameters.TundraMinTemperature, parameters.TundraMaxTemperature, parameters.TundraMinMoisture, parameters.TundraMaxMoisture))
{
return 6;
}
// ... subsequent parent biome checks ...

(Remember to expose BasaltShelfMinTemperature etc. in TerrainGenerationParameters.cs when bundling settings for Burst).

Step 4: Map the Sequential Index in TerrainGenerator.cs

Section titled “Step 4: Map the Sequential Index in TerrainGenerator.cs”

In Assets/_Game/Scripts/World/Generation/TerrainGenerator.cs, ensure the managed switch maps BiomeType.BasaltShelf to 9:

private static int GetSedimentBiomeIndex(BiomeType biome)
{
return biome switch
{
BiomeType.Badlands => 0,
BiomeType.Desert => 1,
BiomeType.Mountains => 2,
BiomeType.Taiga => 3,
BiomeType.Savanna => 4,
BiomeType.Forest => 5,
BiomeType.Tundra => 6,
BiomeType.Jungle => 7,
BiomeType.BasaltShelf => 9,
_ => 8
};
}
  1. In Assets/_Game/Scripts/World/Environment/BiomeWeatherProfile.cs:
public static BiomeWeatherProfile For(BiomeType biome)
{
return biome switch
{
BiomeType.BasaltShelf => new BiomeWeatherProfile(0.04f, false),
BiomeType.Desert => new BiomeWeatherProfile(0.05f, false),
// ...
  1. In Assets/_Game/Scripts/Player/BiomeAmbienceProfile.cs:
public static BiomeAmbienceBed For(BiomeType biome)
{
return biome switch
{
// Volcanic shelf uses dry gusting desert wind with higher nighttime presence
BiomeType.BasaltShelf => new BiomeAmbienceBed(Desert, 0.75f, 0.65f),
BiomeType.Desert => new BiomeAmbienceBed(Desert, 0.70f, 0.62f),
// ...

Step 6: Add Non-Regression Tests in BiomeClassifierTests.cs

Section titled “Step 6: Add Non-Regression Tests in BiomeClassifierTests.cs”

Open Assets/_Game/Tests/EditMode/World/BiomeClassifierTests.cs and append assertions verifying reachability and carve-out boundaries:

[Test]
public void Classify_CanProduceBasaltShelfAcrossARepresentativeArea()
{
var found = new HashSet<BiomeType>();
for (int z = -1536; z <= 1536; z += 48)
{
for (int x = -1536; x <= 1536; x += 48)
{
found.Add(BiomeClassifier.Classify(x, z, settings).Biome);
}
}
Assert.That(found, Does.Contain(BiomeType.BasaltShelf),
"Expected BasaltShelf — a hot, arid volcanic plateau carved out of Desert and Badlands — to be reachable.");
}
[Test]
public void Classify_BasaltShelfIsCarvedOutOfBadlandsAndDesertWithoutLeavingGaps()
{
for (int z = -1536; z <= 1536; z += 48)
{
for (int x = -1536; x <= 1536; x += 48)
{
BiomeClassifier.SampleClimate(x, z, settings, out float temperature, out float moisture);
bool inBasaltWindow = settings.Biomes.BasaltShelf.Matches(temperature, moisture);
if (!inBasaltWindow)
{
continue;
}
BiomeType classified = BiomeClassifier.Classify(x, z, settings).Biome;
Assert.That(classified, Is.EqualTo(BiomeType.BasaltShelf),
"BasaltShelf must take precedence over Desert and Badlands inside its climate window.");
}
}
}

Explore how biomes tile the continuous temperature-versus-moisture plane. Hover over any biome envelope or toggle the Basalt Shelf carve-out to inspect classification precedence:


Run the world generation test suite via Unity batchmode:

Terminal window
/home/soulwax/Unity/Hub/Editor/6000.6.0f1/Editor/Unity \
-batchmode -nographics \
-projectPath . \
-runTests \
-testPlatform EditMode \
-testCategory World \
-testResults Logs/biome-test-results.xml \
-logFile Logs/biome-tests.log

Confirm that BiomeClassifierTests passes with 0 failures.

2. Preview in the Voxel Workshop World Studio

Section titled “2. Preview in the Voxel Workshop World Studio”
  1. In the Unity Editor menu, select Voxel Workshop → World Studio.
  2. Select Biome Classification Map.
  3. Move the preview viewport over high-temperature coordinates and verify dark stone plateau cells render where $T > 0.7$ and $M < -0.4$.

Follow this checklist whenever authoring a new biome:

  1. Pick climate bounds inside $[-1.0, +1.0]$:
    • Frigid polar: $T \in [-1.0, -0.6]$.
    • Temperate: $T \in [-0.3, +0.3]$.
    • Torrid tropical: $T \in [+0.5, +1.0]$.
    • Arid: $M \in [-1.0, -0.3]$.
    • Humid: $M \in [+0.4, +1.0]$.
  2. Identify overlapping parent envelopes:
    • Determine which existing biomes’ windows cover this coordinate region.
  3. Position in ClassificationOrder:
    • Place child biomes before parent biomes.
    • Never move Plains from the final position.
  4. Synchronize Burst & Managed indices:
    • Add the sequential index to TerrainColumnMath.ClassifyBiomeIndex.
    • Update TerrainGenerator.GetSedimentBiomeIndex.
  5. Add weather & audio profiles:
    • Configure precipitation threshold and audio looping bed.
  6. Author carve-out unit tests:
    • Verify non-empty sampling in representative areas.
    • Assert child takes precedence over parent inside its window.

Pitfall 1: Parent envelope listed before child carve-out

Section titled “Pitfall 1: Parent envelope listed before child carve-out”
  • Symptom: The new biome never spawns anywhere in the world, even though its climate window is valid.
  • Cause: In BiomeCatalog.ClassificationOrder, the parent envelope (e.g., Desert or Taiga) appears before the child envelope. The first matching envelope terminates the search.
  • Fix: Place the narrower child biome higher in ClassificationOrder.

Pitfall 2: Direct cast from BiomeType flags to job index

Section titled “Pitfall 2: Direct cast from BiomeType flags to job index”
  • Symptom: Surface blocks fail to generate or default to dirt, or deposit exoticness curves return incorrect values in Burst jobs.
  • Cause: Casting (int)BiomeType.BasaltShelf produces 512 (1 << 9), whereas SurfaceSedimentMath expects sequential index 0..9.
  • Fix: Always pass through GetSedimentBiomeIndex(biome) or maintain the exact unrolled switch.

Pitfall 3: Setting climate coordinates outside $[-1.0, +1.0]$

Section titled “Pitfall 3: Setting climate coordinates outside $[-1.0, +1.0]$”
  • Symptom: The biome matches during manual unit tests with artificial numbers, but never appears during world generation.
  • Cause: DeterministicValueNoise.Sample2D produces values strictly between $-1.0$ and $+1.0$. Setting maxTemperature: 1.5f will never match beyond $1.0$.
  • Fix: Restrict all temperature and moisture ranges strictly to $[-1.0, +1.0]$.

User-contributed notes

Corrections, clarifications, and practical tips for this page. Anonymous is fine — a name is optional. Basic Markdown works: **bold**, *italic*, `code`, and links.

Notes policy

Notes are lightly filtered for spam and may be edited or removed. Keep them about this page — no support requests, no personal data, nothing you would not publish. Links are limited and marked nofollow.

  1. Loading notes…