Infinite Procedural World — How to Implement a High-Performance Map with Pan, Zoom & Interactive POIs

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

  1. Infinite world — chunk coordinates are unbounded signed integers; there is no world border.
  2. Fully deterministic biomesSampleBiome(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).
  3. Fully deterministic plantsEcologyGenerator.Generate(chunkCoord, ...) reproduces the exact plant list for any chunk offline, also without loading it.
  4. Non-deterministic animal positions — actual animal positions are decided by a runtime Random seeded at session start; they cannot be pre-computed and must be tracked live.
  5. 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?

Have in mind that communicating with humans is a bit different than communicating with chatbots, specifically; dumping generated mega-prompts at humans usually won’t achieve anything.

3 Likes

Sorry, and sincerely apologize for that.

English is not my native language, and I’m honestly struggling to explain this clearly. The system I’m working on is fairly large, and even with translation software I’m finding it hard to describe the architecture in a way that is concise and understandable.

I also realize pasting a large AI-organized response was not a good way to communicate here. That was my mistake, and I’m sorry.

I’m still learning, and I’m genuinely just trying to understand the right approach before I go deeper into the wrong solution. I really appreciate any patience or guidance. :sob: :sob: :sob:

I believe that you may have used AI to make the procedural system (maybe), because making a minimap is way simpler than an infinite procedural world.

@sloe-debug The things you have got right now is just a lot of A.I slop/junk,you should start small since you are still learning.

Yes, that’s true — I’m still very new to game development.
I think I’m reasonably comfortable with general code logic, but when it comes to game-specific systems and architecture, such as ECS and similar patterns, I only started learning them recently.

At the moment, I’m mainly trying to figure out the right direction for building the map system, so I don’t keep going deeper into the wrong approach.

What I’m trying to understand is how a chunk-based world like mine is usually supposed to render a minimap and a full world map.

I originally used an orthographic camera approach for the minimap, and that was fine because the visible area was small. But when I added the full-screen map (opened with M), the real problem showed up: I can only display the chunks that are currently loaded, while everything outside the loaded area is just black.

A map is just a different visual representation of the same generated data you use for the primary rendering of the world. If you are able to do one - you should be able to do the other.

Well load all the chunks you need.

The problem is the unloaded chunks. On the full map, they make the world look incomplete. It breaks immersion a bit — like in a movie when you accidentally see the film crew in the shot.

Don’t unload them if they are needed to render the map.

If the chunk hasn’t even loaded(generated) yet,how the system will know that how it looks? you can do 2 things:
1.Show a cool line that indicates that the map is not loaded yet, like just show a label that says “explore more to unlock”

2.After loading all of the chunks, save them in a file,as a img or something.

If the solution is to load more chunks for the full map, does that mean I also need to load static world content like vegetation for those chunks, even when they are far outside the player’s active area? That’s the part I’m still unsure about.

But if I keep those chunks loaded, wouldn’t that create a new problem with performance and resource usage?

Hmm, I’ll definitely think about that. But I feel like that might defeat the purpose of having a full world map. Your idea is really interesting though — thank you very much for the guidance.

No, it doesn’t.
you have to just go to the place, you don’t have to pay or defeat a dragon lol

You’ll obviously need to load/keep every information required by the map, which will depend on the visual design and detail level of the map. So you should start by defining the look/granularity of your map in respect to your game data.

More data doesn’t necessarily mean “performance problems”.
If you want to show 100 chunks you’ll have to keep them in memory, one way or another. You can’t show what you don’t have.

I think I understand now. What you mean is that, combining the suggestion from another person in this thread, I only need to save the areas the player has visited, render those chunks into map images (possibly at different zoom levels), and cache them. Then the unexplored chunks can just remain hidden under fog/black on the map. Is that correct?

Something like that, yes, but again, it will depend on the visual design of the map. If the chunk representation on the map is mainly static, you can render it once when the chunk is first loaded and then maintain the cache of that rendered image, and use it as needed. If there are dynamic/moving parts, you can draw them on top those cached images.

Thank you very much for the suggestion. I’ll try following this approach and see how it goes.