Godot Version
Godot 4.6,Language:C#
Question
1. Coordinate System
World Space
└─ Tile coords (tileX, tileZ) integers, 1 tile = 1 metre
└─ Chunk coords (chunkX, chunkZ) integers, 1 chunk = 32 tiles (configurable)
└─ Macro-cell coords (macroX, macroZ) integers, 1 macro-cell = 64 tiles (configurable)
- Unbounded: all coordinates are arbitrary signed integers; the world is theoretically infinite.
- Terrain is fixed at Y = 0 (flat world, no height variation).
- Chunk is the smallest load/unload unit; macro-cell is the biome assignment unit.
2. Terrain Generation
2.1 Biome Classification (macro-cell level)
Each macro-cell’s biome is determined by two independent Value Noise fields — temperature and humidity:
// Pseudocode: macro-cell (mx, mz) → biome
function GetMacroBiome(mx, mz):
temperature = ValueNoise(mx, mz, seedTemp, scale=8) // [0, 1)
humidity = ValueNoise(mx, mz, seedHum, scale=8) // [0, 1)
return Classify(temperature, humidity)
function Classify(temp, hum):
if temp < 0.25: return Snowfield // cold
if temp > 0.85 and hum < 0.20: return Volcano // extreme heat, dry
if temp > 0.65 and hum < 0.35: return Desert // hot, dry
if temp > 0.40 and hum > 0.70: return Swamp // warm, very wet
if temp > 0.35 and hum > 0.50: return Forest // warm, humid
return Grassland // everything else
6 biomes: Grassland / Forest / Desert / Snowfield / Volcano / Swamp
2.2 Biome Sampling (tile level)
To produce organic curved borders instead of grid-aligned edges, a two-step algorithm is used:
// Pseudocode: any tile coord → biome
function SampleBiome(tileX, tileZ):
// Step 1: Domain Warp — perturb the sample coord with smooth noise
warpScale = max(16, cellSizeTiles * 0.375)
warpX = (ValueNoise(tileX, tileZ, seedWarpX, warpScale) - 0.5) * 2 * borderJitter
warpZ = (ValueNoise(tileX, tileZ, seedWarpZ, warpScale) - 0.5) * 2 * borderJitter
wx = tileX + warpX // warped "virtual" position
wz = tileZ + warpZ
// Step 2: Voronoi — find the nearest macro-cell seed point in a 3×3 neighbourhood
mx0 = FloorDiv(wx, cellSizeTiles)
mz0 = FloorDiv(wz, cellSizeTiles)
bestDist = ∞
for dz in [-1, 0, 1]:
for dx in [-1, 0, 1]:
(cx, cz) = GetMacroCenter(mx0+dx, mz0+dz) // jittered seed point
dist = squaredDistance((wx, wz), (cx, cz))
if dist < bestDist:
bestDist = dist
bestBiome = GetMacroBiome(mx0+dx, mz0+dz)
return bestBiome
// Macro-cell seed point (±35% hash jitter to break grid regularity)
function GetMacroCenter(mx, mz):
baseX = mx * cellSizeTiles + cellSizeTiles / 2
baseZ = mz * cellSizeTiles + cellSizeTiles / 2
jx = (Hash2DNorm(mx, mz, seedJitterX) - 0.5) * 2 * cellSizeTiles * 0.35
jz = (Hash2DNorm(mx, mz, seedJitterZ) - 0.5) * 2 * cellSizeTiles * 0.35
return (baseX + jx, baseZ + jz)
Result: every tile deterministically resolves to a biome with smooth, organic borders.
2.3 Chunk Generation Pipeline (background thread)
// Pseudocode: generate one 32×32 chunk
function GenerateChunk(chunkCoord):
data = new ChunkData(32×32 tiles)
// Phase 1: per-tile biome sampling (Domain Warp + Voronoi)
for lx in [0, 31]:
for lz in [0, 31]:
tileX = chunkCoord.X * 32 + lx
tileZ = chunkCoord.Z * 32 + lz
data.Tiles[lx, lz] = SampleBiome(tileX, tileZ)
// Phase 2: small-patch elimination (two FloodFill passes)
for pass in [0, 1]:
labels = FloodFill(data.Tiles) // label connected regions
for each region lab:
if area(lab) < minPatchArea:
// merge this isolated fragment into its largest neighbouring biome
MergeToBiggestNeighbor(lab)
return data
Small-patch elimination removes isolated single-tile biome fragments,
producing cleaner biome boundaries.
2.4 Streaming Loader (main-thread scheduler)
// Pseudocode: called every frame
function TerrainChunkLoaderSystem.Update():
playerChunk = WorldToChunk(player.position)
desired = BuildChunkSet(playerChunk, preloadRadius=4) // square set around player
// Schedule generation of new chunks on background threads
for coord in desired - loaded:
Task.Run(() => GenerateChunk(coord) → readyQueue)
// Drain the ready queue on the main thread (max 2 per frame to avoid stalls)
while readyQueue.Count > 0 and submitsThisFrame < 2:
chunk = readyQueue.Dequeue()
if chunk.coord ∈ desired: // still needed
ApplyToViewManager(chunk)
submitsThisFrame++
// Unload chunks that have moved out of range
for coord in loaded - desired:
UnloadChunk(coord)
3. Plant Generation
Plants are generated deterministically on a background thread when a chunk is loaded.
No runtime RNG is involved.
3.1 Generation Algorithm (plant channel)
// Pseudocode: generate plants for one chunk
function GeneratePlants(chunkCoord):
// Extended area: sample 6 extra tiles beyond the chunk border to avoid seam gaps
extendedArea = [chunkOrigin - 6, chunkOrigin + chunkSize + 6]
candidates = []
// Scan the extended area on a grid with step = EcoCellSizeTiles = 4
for (cx, cz) in grid(extendedArea, step=cellSize=4):
biome = SampleBiome(cx + 2, cz + 2) // sample at cell centre
plants = GetPlantsForBiome(biome)
if plants is empty: continue
// Probability gate: density × cell area × plant-weight fraction
density = GetBiomeDensity(biome) // e.g. Forest = 1.5 plants / 100 m²
cellArea = 4 × 4 × 1.0 × 1.0 // 16 m²
spawnProb = density × cellArea / 100 × (plantWeight / totalWeight)
if Hash2DNorm(cx, cz, plantSeed) >= spawnProb: continue
// Deterministic weighted species selection
species = PickWeightedSpecies(plants, cx, cz, plantSeed)
// Deterministic sub-cell random offset (scatter within the cell)
offsetX = Hash2DNorm(cx ^ 0xABCD, cz, plantSeed ^ 0x5678) × cellSize
offsetZ = Hash2DNorm(cx, cz ^ 0xDCBA, plantSeed ^ 0x9ABC) × cellSize
worldPos = (cx * tileSize + offsetX, cz * tileSize + offsetZ)
candidates.add((worldPos, species))
// Sort by cell coord (ensures a consistent order) → spacing filter
Sort(candidates by cellX, cellZ)
accepted = []
for candidate in candidates:
if PassesMinDistance(candidate, accepted):
accepted.add(candidate)
// Clip to chunk boundary, assign deterministic size and yaw
seeds = []
for (worldPos, species) in accepted:
if worldPos is outside chunk boundary: continue
sizeNorm = Hash2DNorm(worldPos.x × 100, worldPos.z × 100, plantSeed)
size3D = species.MinSize + (species.MaxSize - species.MinSize) × sizeNorm
yaw = Hash2DNorm(...) × 2π
seeds.add(PlantSpawnSeed { species, localPos, size3D, yaw })
if seeds.Count > 20: TrimTinyFirst(seeds, limit=20)
return seeds
Key property: for a given worldSeed, the plant list for any chunk is fully deterministic.
It can be computed offline for unloaded regions — this is important for map rendering.
3.2 Minimum Spacing Rules
| Size class | MinSize.X condition | Min spacing |
|---|---|---|
| Large | ≥ 1.2 m | 3.5 m |
| Medium | ≥ 0.65 m | 2.0 m |
| Small | ≥ 0.38 m | 1.0 m |
| Tiny | < 0.38 m | 0.4 m |
4. Animal (Monster) Generation
Animals use a spawn-cell + runtime dynamic population architecture,
which differs from the purely deterministic plant system.
4.1 Spawn Cell Generation (deterministic, background thread)
Generated in the same batch as plants, using the same cell grid:
// Pseudocode: generate animal spawn cells for one chunk
function GenerateSpawnCells(chunkCoord):
cells = []
for (cx, cz) in grid(extendedArea, step=cellSize=4):
centerWorld = ((cx + cellSize/2) * tileSize, (cz + cellSize/2) * tileSize)
// Only emit cells whose centre falls inside this chunk
// (guarantees each cell belongs to exactly one chunk)
if centerWorld is outside chunk boundary: continue
biome = SampleBiome(cx + cellSize/2, cz + cellSize/2)
animals = GetAnimalsForBiome(biome)
if animals is empty: continue
cells.add(AnimalSpawnCell {
cellId = (cellIndexX << 32) | cellIndexZ, // globally unique ID
centerWorld = centerWorld,
biome = biome,
habitatWeight = sum(animal.Weight for animal in animals)
})
return cells
Spawn cells are candidate locations only — no animal entities are created at this stage.
4.2 Runtime Population System (main thread, every frame)
// Pseudocode: AnimalPopulationSystem.Update()
function Update(delta):
// ── DespawnPass: recycle animals that are too far away ──
for each active animal:
dist = distance(animal.position, player.position)
if dist > despawnDistance=112m and not animal.Persistent:
DestroyAnimalEntity(animal)
MarkFaunaViewDirty()
// ── SpawnPass: attempt spawning on a fixed interval ──
spawnTimer += delta
if spawnTimer < spawnInterval=1.0s: return
spawnTimer = 0
if totalAnimalCount >= globalCap=48: return // hard global cap
// Query all loaded spawn cells
spawnCells = Query<AnimalSpawnCellComponent>()
eligibleCells = spawnCells.filter(cell =>
spawnMinDist=24m <= distance(cell.center, player) <= spawnMaxDist=96m
)
for attempt in [0, attemptsPerTick=8):
cell = RandomPick(eligibleCells) // runtime RNG — non-deterministic
if animalCount[cell.chunk] >= chunkCap=4: continue // per-chunk cap
species = WeightedRandom(GetAnimalsForBiome(cell.biome))
offsetX = Random.NextDouble() × cellSize // random offset prevents fixed spawn points
offsetZ = Random.NextDouble() × cellSize
spawnPos = cell.centerWorld + (offsetX, 0, offsetZ)
CreateAnimalEntity(species, spawnPos, cell.chunk)
MarkFaunaViewDirty()
Key distinction:
- Spawn cell positions are deterministic (can be pre-computed)
- Actual animal positions are decided by runtime RNG (different every session, cannot be pre-computed)
5. Key Parameters
| Parameter | Value | Description |
|---|---|---|
| ChunkSizeTiles | 32 | Chunk edge length (tiles) |
| TileWorldSize | 1.0 m | World size per tile |
| Chunk world size | 32 m | = ChunkSizeTiles × TileWorldSize |
| BiomeCellSizeTiles | 64 | Macro-cell edge length (tiles); biome assignment granularity |
| BorderJitterTiles | 12 | Biome border warp amplitude (tiles) |
| PreloadRadiusChunks | 4 | Load / unload radius (chunks) |
| Loaded diameter | ~256 m | = 2 × 4 × 32 m |
| EcoCellSizeTiles | 4 | Ecology sampling cell edge (tiles) |
| MaxInstancesPerChunk | 20 | Hard plant cap per chunk |
| GlobalAnimalCap | 48 | Hard global animal cap |
| ChunkAnimalCap | 4 | Hard animal cap per chunk |
6. Implications for Map Feature Design
6.1 Known Properties
- Infinite world — chunk coordinates are unbounded signed integers; there is no world border.
- Fully deterministic biomes —
SampleBiome(tileX, tileZ)can be called at any coordinate at any time, without loading the chunk, from any thread. It is pure, stateless, and O(1). - Fully deterministic plants —
EcologyGenerator.Generate(chunkCoord, ...)reproduces the exact plant list for any chunk offline, also without loading it. - Non-deterministic animal positions — actual animal positions are decided by a runtime
Randomseeded at session start; they cannot be pre-computed and must be tracked live. - Flat terrain — Y is always 0; there is no elevation data to represent on a map.
6.2 Questions I’d Like to Ask
The game already has a minimap and a full-screen map, but they currently work by sampling already-loaded chunks — the map only has data for areas the player has visited.
Given the architecture above, here are the questions I am looking for advice on:
Q1: For an infinite world where biomes can be sampled at any coordinate without loading chunks, what are the common strategies for implementing a minimap and a full-screen map?
Q2: Should the full-screen map pre-generate a large biome texture asynchronously on a background thread (e.g. an N×N chunk overview centred on the player), or is there a better approach?
Q3: How should the map update smoothly as the player moves — full redraw of the viewport, or incremental tile-by-tile updates?
Q4: Should plant density be visualised on the map? If so, what is a reasonable minimum rendering granularity?
Q5: For this kind of architecture, how is LOD (level-of-detail) map data typically structured?