skills/taozhuo/game-dev-skills/procedural-generation

procedural-generation

SKILL.md

Roblox Procedural Generation

When implementing procedural generation, use these patterns for performant and interesting content.

Noise Functions

Multi-Octave Perlin Noise

local function octaveNoise(x, y, octaves, persistence, scale, lacunarity)
    octaves = octaves or 4
    persistence = persistence or 0.5
    scale = scale or 1
    lacunarity = lacunarity or 2

    local total = 0
    local frequency = scale
    local amplitude = 1
    local maxValue = 0

    for i = 1, octaves do
        total = total + math.noise(x * frequency, y * frequency) * amplitude
        maxValue = maxValue + amplitude
        amplitude = amplitude * persistence
        frequency = frequency * lacunarity
    end

    return total / maxValue  -- Normalize to [-1, 1]
end

-- Usage for terrain
local function getTerrainHeight(x, z)
    local baseHeight = octaveNoise(x, z, 4, 0.5, 0.01) * 50  -- Large features
    local detail = octaveNoise(x, z, 2, 0.5, 0.1) * 5        -- Small details
    return baseHeight + detail + 10  -- Base height offset
end

Domain Warping

-- Use noise to distort noise coordinates for more organic shapes
local function warpedNoise(x, y, scale, warpStrength)
    local warpX = math.noise(x * scale, y * scale, 0) * warpStrength
    local warpY = math.noise(x * scale, y * scale, 100) * warpStrength

    return math.noise((x + warpX) * scale, (y + warpY) * scale)
end

-- Creates more interesting, swirly patterns
local function getWarpedTerrainHeight(x, z)
    local warp1 = warpedNoise(x, z, 0.005, 50)
    local warp2 = warpedNoise(x, z, 0.02, 10)
    return (warp1 * 40 + warp2 * 10) + 20
end

Ridged Noise (Mountains)

local function ridgedNoise(x, y, octaves, scale)
    local total = 0
    local frequency = scale
    local amplitude = 1
    local weight = 1

    for i = 1, octaves do
        local noise = math.noise(x * frequency, y * frequency)
        noise = 1 - math.abs(noise)  -- Create ridges
        noise = noise * noise        -- Sharpen ridges
        noise = noise * weight
        weight = math.clamp(noise * 2, 0, 1)

        total = total + noise * amplitude
        amplitude = amplitude * 0.5
        frequency = frequency * 2
    end

    return total
end

Terrain Generation

Heightmap-Based Terrain

local TerrainGenerator = {}

function TerrainGenerator.generateChunk(chunkX, chunkZ, chunkSize, resolution)
    local terrain = workspace.Terrain
    local heightMap = {}

    -- Generate height map
    for x = 0, resolution do
        heightMap[x] = {}
        for z = 0, resolution do
            local worldX = chunkX * chunkSize + (x / resolution) * chunkSize
            local worldZ = chunkZ * chunkSize + (z / resolution) * chunkSize

            local height = getTerrainHeight(worldX, worldZ)
            heightMap[x][z] = height
        end
    end

    -- Fill terrain
    local cellSize = chunkSize / resolution
    for x = 0, resolution - 1 do
        for z = 0, resolution - 1 do
            local worldX = chunkX * chunkSize + x * cellSize
            local worldZ = chunkZ * chunkSize + z * cellSize

            local h1 = heightMap[x][z]
            local h2 = heightMap[x + 1][z]
            local h3 = heightMap[x][z + 1]
            local h4 = heightMap[x + 1][z + 1]

            local minH = math.min(h1, h2, h3, h4)
            local maxH = math.max(h1, h2, h3, h4)

            local material = TerrainGenerator.getMaterial(maxH, maxH - minH)

            terrain:FillBlock(
                CFrame.new(worldX + cellSize/2, (minH + maxH)/2, worldZ + cellSize/2),
                Vector3.new(cellSize, maxH - minH + 1, cellSize),
                material
            )
        end
    end
end

function TerrainGenerator.getMaterial(height, slope)
    if slope > 2 then
        return Enum.Material.Rock  -- Steep = rock
    elseif height > 80 then
        return Enum.Material.Snow  -- High = snow
    elseif height > 40 then
        return Enum.Material.Rock
    elseif height > 5 then
        return Enum.Material.Grass
    else
        return Enum.Material.Sand  -- Low = sand/beach
    end
end

Biome System

local function getBiome(x, z)
    local temperature = octaveNoise(x, z, 2, 0.5, 0.001) -- -1 to 1
    local moisture = octaveNoise(x + 1000, z + 1000, 2, 0.5, 0.001)

    temperature = (temperature + 1) / 2  -- 0 to 1
    moisture = (moisture + 1) / 2

    if temperature < 0.3 then
        return moisture > 0.5 and "snow_forest" or "tundra"
    elseif temperature < 0.6 then
        if moisture < 0.3 then
            return "plains"
        elseif moisture < 0.7 then
            return "forest"
        else
            return "swamp"
        end
    else
        return moisture < 0.4 and "desert" or "jungle"
    end
end

local BiomeSettings = {
    desert = {
        heightScale = 20,
        material = Enum.Material.Sand,
        treeDensity = 0,
        grassDensity = 0
    },
    forest = {
        heightScale = 40,
        material = Enum.Material.Grass,
        treeDensity = 0.3,
        grassDensity = 0.8
    },
    -- etc.
}

Dungeon Generation

BSP (Binary Space Partitioning)

local DungeonGenerator = {}

local function splitRoom(room, minSize)
    local rooms = {}

    local canSplitH = room.width >= minSize * 2
    local canSplitV = room.height >= minSize * 2

    if not canSplitH and not canSplitV then
        return {room}
    end

    local splitHorizontal
    if canSplitH and canSplitV then
        splitHorizontal = math.random() > 0.5
    else
        splitHorizontal = canSplitH
    end

    if splitHorizontal then
        local splitX = room.x + math.random(minSize, room.width - minSize)
        local room1 = {x = room.x, y = room.y, width = splitX - room.x, height = room.height}
        local room2 = {x = splitX, y = room.y, width = room.x + room.width - splitX, height = room.height}

        for _, r in ipairs(splitRoom(room1, minSize)) do
            table.insert(rooms, r)
        end
        for _, r in ipairs(splitRoom(room2, minSize)) do
            table.insert(rooms, r)
        end
    else
        local splitY = room.y + math.random(minSize, room.height - minSize)
        local room1 = {x = room.x, y = room.y, width = room.width, height = splitY - room.y}
        local room2 = {x = room.x, y = splitY, width = room.width, height = room.y + room.height - splitY}

        for _, r in ipairs(splitRoom(room1, minSize)) do
            table.insert(rooms, r)
        end
        for _, r in ipairs(splitRoom(room2, minSize)) do
            table.insert(rooms, r)
        end
    end

    return rooms
end

function DungeonGenerator.generate(width, height, minRoomSize)
    local initialRoom = {x = 0, y = 0, width = width, height = height}
    local partitions = splitRoom(initialRoom, minRoomSize)

    -- Shrink partitions to create rooms with walls between
    local rooms = {}
    for _, partition in ipairs(partitions) do
        local padding = 2
        local room = {
            x = partition.x + padding,
            y = partition.y + padding,
            width = partition.width - padding * 2,
            height = partition.height - padding * 2
        }
        if room.width > 0 and room.height > 0 then
            table.insert(rooms, room)
        end
    end

    -- Connect rooms with corridors
    local corridors = {}
    for i = 1, #rooms - 1 do
        local r1 = rooms[i]
        local r2 = rooms[i + 1]

        local x1 = r1.x + r1.width / 2
        local y1 = r1.y + r1.height / 2
        local x2 = r2.x + r2.width / 2
        local y2 = r2.y + r2.height / 2

        -- L-shaped corridor
        if math.random() > 0.5 then
            table.insert(corridors, {x1 = x1, y1 = y1, x2 = x2, y2 = y1})
            table.insert(corridors, {x1 = x2, y1 = y1, x2 = x2, y2 = y2})
        else
            table.insert(corridors, {x1 = x1, y1 = y1, x2 = x1, y2 = y2})
            table.insert(corridors, {x1 = x1, y1 = y2, x2 = x2, y2 = y2})
        end
    end

    return rooms, corridors
end

function DungeonGenerator.buildInWorld(rooms, corridors, floorHeight)
    local dungeonModel = Instance.new("Model")
    dungeonModel.Name = "Dungeon"

    -- Build rooms
    for _, room in ipairs(rooms) do
        local floor = Instance.new("Part")
        floor.Size = Vector3.new(room.width, 1, room.height)
        floor.Position = Vector3.new(room.x + room.width/2, floorHeight, room.y + room.height/2)
        floor.Anchored = true
        floor.Material = Enum.Material.Cobblestone
        floor.Parent = dungeonModel

        -- Walls
        -- ... add wall parts
    end

    -- Build corridors
    for _, corridor in ipairs(corridors) do
        local dx = corridor.x2 - corridor.x1
        local dy = corridor.y2 - corridor.y1
        local length = math.sqrt(dx*dx + dy*dy)

        local floor = Instance.new("Part")
        floor.Size = Vector3.new(3, 1, length)
        floor.CFrame = CFrame.lookAt(
            Vector3.new((corridor.x1 + corridor.x2)/2, floorHeight, (corridor.y1 + corridor.y2)/2),
            Vector3.new(corridor.x2, floorHeight, corridor.y2)
        )
        floor.Anchored = true
        floor.Material = Enum.Material.Cobblestone
        floor.Parent = dungeonModel
    end

    dungeonModel.Parent = workspace
    return dungeonModel
end

Cellular Automata (Caves)

local function generateCave(width, height, fillChance, iterations)
    -- Initialize with random fill
    local grid = {}
    for x = 1, width do
        grid[x] = {}
        for y = 1, height do
            if x == 1 or x == width or y == 1 or y == height then
                grid[x][y] = 1  -- Walls at edges
            else
                grid[x][y] = math.random() < fillChance and 1 or 0
            end
        end
    end

    -- Apply cellular automata rules
    for _ = 1, iterations do
        local newGrid = {}
        for x = 1, width do
            newGrid[x] = {}
            for y = 1, height do
                local neighbors = 0
                for dx = -1, 1 do
                    for dy = -1, 1 do
                        if dx ~= 0 or dy ~= 0 then
                            local nx, ny = x + dx, y + dy
                            if nx >= 1 and nx <= width and ny >= 1 and ny <= height then
                                neighbors = neighbors + grid[nx][ny]
                            else
                                neighbors = neighbors + 1  -- Out of bounds = wall
                            end
                        end
                    end
                end

                -- Rule: become wall if 5+ neighbors are walls
                if neighbors >= 5 then
                    newGrid[x][y] = 1
                elseif neighbors <= 3 then
                    newGrid[x][y] = 0
                else
                    newGrid[x][y] = grid[x][y]
                end
            end
        end
        grid = newGrid
    end

    return grid
end

Object Placement

Poisson Disc Sampling

local function poissonDiscSampling(width, height, minDistance, maxAttempts)
    maxAttempts = maxAttempts or 30
    local cellSize = minDistance / math.sqrt(2)
    local gridWidth = math.ceil(width / cellSize)
    local gridHeight = math.ceil(height / cellSize)

    local grid = {}
    for i = 1, gridWidth do
        grid[i] = {}
    end

    local points = {}
    local activeList = {}

    -- Start with random point
    local startX = math.random() * width
    local startY = math.random() * height
    table.insert(points, {x = startX, y = startY})
    table.insert(activeList, 1)

    local gx = math.floor(startX / cellSize) + 1
    local gy = math.floor(startY / cellSize) + 1
    grid[gx][gy] = 1

    while #activeList > 0 do
        local activeIndex = math.random(#activeList)
        local currentPoint = points[activeList[activeIndex]]
        local found = false

        for _ = 1, maxAttempts do
            local angle = math.random() * math.pi * 2
            local distance = minDistance + math.random() * minDistance

            local newX = currentPoint.x + math.cos(angle) * distance
            local newY = currentPoint.y + math.sin(angle) * distance

            if newX >= 0 and newX < width and newY >= 0 and newY < height then
                local gx = math.floor(newX / cellSize) + 1
                local gy = math.floor(newY / cellSize) + 1

                local valid = true

                -- Check neighbors
                for dx = -2, 2 do
                    for dy = -2, 2 do
                        local checkX = gx + dx
                        local checkY = gy + dy

                        if checkX >= 1 and checkX <= gridWidth and
                           checkY >= 1 and checkY <= gridHeight and
                           grid[checkX][checkY] then

                            local otherPoint = points[grid[checkX][checkY]]
                            local dist = math.sqrt((newX - otherPoint.x)^2 + (newY - otherPoint.y)^2)

                            if dist < minDistance then
                                valid = false
                                break
                            end
                        end
                    end
                    if not valid then break end
                end

                if valid then
                    local newIndex = #points + 1
                    table.insert(points, {x = newX, y = newY})
                    table.insert(activeList, newIndex)
                    grid[gx][gy] = newIndex
                    found = true
                    break
                end
            end
        end

        if not found then
            table.remove(activeList, activeIndex)
        end
    end

    return points
end

-- Place trees using Poisson disc
local function placeVegetation(area, density)
    local minDistance = 5 / density  -- Higher density = closer spacing
    local points = poissonDiscSampling(area.width, area.height, minDistance)

    for _, point in ipairs(points) do
        local worldX = area.x + point.x
        local worldZ = area.y + point.y

        -- Raycast down to find ground
        local ray = workspace:Raycast(
            Vector3.new(worldX, 1000, worldZ),
            Vector3.new(0, -2000, 0)
        )

        if ray then
            local tree = ReplicatedStorage.Trees:GetChildren()[math.random(#ReplicatedStorage.Trees:GetChildren())]:Clone()
            tree:PivotTo(CFrame.new(ray.Position))
            tree.Parent = workspace.Vegetation
        end
    end
end

Seeded Generation

Deterministic Random

local SeededRandom = {}

function SeededRandom.new(seed)
    local rng = Random.new(seed)
    return {
        next = function(self, min, max)
            if max then
                return rng:NextInteger(min, max)
            elseif min then
                return rng:NextInteger(1, min)
            else
                return rng:NextNumber()
            end
        end,
        shuffle = function(self, array)
            local n = #array
            for i = n, 2, -1 do
                local j = rng:NextInteger(1, i)
                array[i], array[j] = array[j], array[i]
            end
            return array
        end
    }
end

-- Reproducible world generation
local function generateWorld(seed)
    local rng = SeededRandom.new(seed)

    -- Same seed always produces same world
    local numRooms = rng:next(5, 10)
    local rooms = {}

    for i = 1, numRooms do
        table.insert(rooms, {
            x = rng:next(0, 100),
            y = rng:next(0, 100),
            width = rng:next(5, 15),
            height = rng:next(5, 15)
        })
    end

    return rooms
end
Weekly Installs
2
First Seen
Jan 25, 2026
Installed on
windsurf1
opencode1
cursor1
codex1
claude-code1
antigravity1