Moud

World generation#

A place can make its terrain from a script instead of, or on top of, the terrain you build in the editor. The script describes the world once: which noise shapes the ground, where the caves go, what the surface is made of, where water sits, which ores, trees and buildings are scattered over it. Moud runs it for every chunk the game needs, on every core, from a seed, so the same seed always makes the same world.

This page covers turning generation on, the generator script, noise, densities, the chunk and everything it can do, decorations and structures that cross chunk borders, biomes, height maps, how generated terrain and the scene's terrain share the level, and the editor commands.

Turning it on#

Point [generation] in place.toml at a script:

toml
[generation]
script = "res://generation/world.luau"
seed = 1234
Setting
script the generator, a .luau file in the place
seed the world seed. Leave it out and the place id is the seed
terrain how the scene's own terrain and the generated terrain share a chunk: "replace", "merge" or "ignore", see Generated and built terrain
radius how far from the origin, in blocks, the world goes. Leave it out for a world that never ends

With no radius the world is endless: chunks keep generating wherever players go, in the editor and in the game.

The generator script#

The script returns a table with up to two functions:

luau
-- generation/world.luau
local ground = noise.new { scale = 200, octaves = 5, range = { 50, 90 } }

return {
    terrain = function(chunk)
        chunk:columns(density.noise(ground), "stone")
        chunk:surface { top = "grass_block", under = "dirt", depth = 3 }
        chunk:water(62)
    end,

    decorate = function(chunk)
        -- trees, flowers, anything that may reach into the next chunk
    end,
}
  • terrain(chunk) builds one chunk: its ground, caves, surface, water, ores and biomes. It sees only its own chunk, which is what lets many chunks build at once.
  • decorate(chunk) runs once the chunk and the eight around it have their terrain. It can read them all and write up to 16 blocks past its own edges, which is what trees and small buildings need.
  • A script that returns a function instead of a table is a terrain function with no decorate.

The generator does not run where your other scripts run. It runs in Luau VMs of its own, one per worker thread, while Minecraft builds chunks. It has no game, no instances and no task; it has what building terrain needs:

Global
noise seeded noise, see Noise
density noise and math combined into one shape, see Densities
block(name) the number that stands for a block, for fast comparisons
prefab small pieces of blocks to stamp into chunks, see Prefabs
image(path) a picture read as a height or biome map, see Maps from pictures
generation seed, minY, maxY, random(seed), points(...) and hash(x, y, z?)
require, print as everywhere else; require loads a module into the generator's VM

The script and everything it requires load once per VM. Build your noise, densities and prefabs at the top of the file, not inside terrain, so they are made once instead of once per chunk.

A generator must give the same chunk every time it is asked for the same place. Take randomness from chunk:random(), generation.random(seed) or a noise, never from math.random, and keep no state between chunks: each worker has its own copy of your globals.

Noise#

noise.new(options) makes a noise. Every option has a default, so noise.new() is a simplex noise with features about 100 blocks across:

luau
local hills = noise.new {
    kind = "simplex",      -- "simplex", "perlin", "value", "cellular" or "white"
    seed = 1,              -- mixed with the world seed, so two noises with different seeds differ
    scale = 200,           -- how many blocks one feature spans; or set frequency = 1 / 200
    fractal = "fbm",       -- "none", "fbm", "ridged" or "billow"
    octaves = 5,           -- layers of finer detail on top of each other
    lacunarity = 2,        -- how much finer each layer is
    gain = 0.5,            -- how much weaker each layer is
    range = { 50, 90 },    -- what the noise returns instead of -1 to 1
    warp = { amplitude = 30, scale = 120, octaves = 2 },  -- bend the input for less regular shapes
}

hills:get(x, z)            -- 2D
hills:get(x, y, z)         -- 3D
  • fbm adds octaves; ridged folds each one so its middle becomes a sharp ridge, the shape of mountain crests and tunnels; billow folds the other way into round lumps, the shape of dunes and clouds.
  • Setting octaves above 1 without a fractal means fbm.
  • warp moves each point by another noise before sampling, which turns round blobs into flowing, twisted shapes. warp = 30 is shorthand for { amplitude = 30 }.

A cellular noise splits the world into cells around scattered points, the pattern of cracked mud and giraffe skin. cell chooses what it returns: "distance" to the nearest point, "distance2" to the second nearest, "edge" for thin lines between cells, or "value" for one random value per cell. jitter from 0 to 1 is how far the points stray from a regular grid, and metric is "euclidean", "manhattan" or "chebyshev". To know which cell a point is in, ask:

luau
local regions = noise.new { kind = "cellular", scale = 400 }
local cell = regions:cell(x, z)   -- { id, value, distance, distance2, x, y, z }

id is the same number everywhere inside one cell, and x, y, z is the cell's point, which makes cells a way to cut the world into regions with a centre each.

Calling get once per block from Luau is slow. Ask for a whole grid in one call instead:

luau
local heights = hills:map2(chunk.x, chunk.z, 16, 16)            -- 256 numbers, row by row
local caves = cavern:map3(chunk.x, 0, chunk.z, 16, 64, 16)      -- y, then z, then x
local reused = hills:map2(chunk.x, chunk.z, 16, 16, 1, buf)     -- step 1, written into a buffer as f32

noise is a global in every script, not only in generators, so a client can use the same noise for grass sway or a map screen. Outside a generator there is no world seed; the seed option is the whole seed.

Densities#

A density describes a shape as a number at every point: positive inside the ground, negative in the air. You build it once, from noises, density.x, density.y, density.z and arithmetic, and Moud runs it in Java for whole chunks at a time:

luau
local ground = noise.new { scale = 300, octaves = 4 }
local overhangs = noise.new { seed = 1, scale = 60, octaves = 3 }

local height = 70 + density.noise(ground) * 25               -- a number per column
local land = (height - density.y) / 12                        -- positive under the height
    + density.noise3(overhangs) * 0.6                         -- 3D noise carves arches and overhangs

return function(chunk)
    chunk:shape(land, "stone")
end
  • density.noise(n) is a 2D noise, one value per column. density.noise3(n) is 3D.
  • +, -, *, / and unary - work between densities and numbers.
  • density.min, max, abs, clamp(d, low, high), lerp(t, a, b), smoothstep(d, from, to), constant(v).
  • density.spline(d, points) maps a value through a curve of { input, output } points, smoothed between them. It is how one noise becomes a coastline, plains and then steep mountains:
luau
local continent = density.noise(noise.new { scale = 900, octaves = 3 })
local height = density.spline(continent, { { -1, 30 }, { -0.1, 58 }, { 0.05, 66 }, { 0.5, 90 }, { 1, 170 } })
  • Densities also have methods for chaining: d:abs(), d:square(), d:cube(), d:clamp(low, high), d:smoothstep(from, to), d:spline(points).
  • d:get(x, z) or d:get(x, y, z) evaluates it at one point, which is how a structure finds the ground height at a spot in another chunk without that chunk existing yet.

Three chunk methods take a density:

chunk:columns(height, block, bottom?) fills each column from bottom (or the bottom of the world) up to height, exactly, once per column
chunk:shape(density, block, options?) fills every block where the density is above 0
chunk:carve(density, options?) turns every block where the density is above 0 into air, or into with

shape and carve sample the density on a coarse grid, every 4 blocks across and 8 up for shape and every 4 for carve, and blend between the samples, the way Minecraft builds its own terrain. That is what makes 3D shapes cheap. cell (1, 2, 4, 8 or 16) and cellY change the spacing, and minY and maxY limit the height they cover. Parts of a density that do not depend on y are worked out once per column however tall the chunk is.

The chunk#

terrain and decorate get a chunk. Every position is a world position, in blocks.

Field
x, z the chunk's lowest corner, a multiple of 16
cx, cz the chunk's own coordinates, x // 16 and z // 16
minY, maxY the lowest and highest block of the world, -64 and 319
seed a number made from the world seed and this chunk

Blocks are names such as "stone" or "minecraft:oak_log[axis=x]", or the number block(name) returns. Numbers are faster: make them once at the top of the script.

luau
local STONE, GRASS = block "stone", block "grass_block"

chunk:get(x, y, z)                         -- the number of the block there, 0 is air
chunk:set(x, y, z, GRASS)
chunk:fill(x0, y0, z0, x1, y1, z1, STONE)  -- a box, clipped to what the chunk may write
chunk:column(x, z, top, STONE, bottom?)    -- from bottom (default the bottom of the world) to top
chunk:height(x, z)                         -- the highest block that is not air
chunk:replace(STONE, "deepslate", chunk.minY, 0)

In terrain, writes outside the chunk are dropped and reads outside it are air. In decorate, reads and writes reach the chunks around it.

Layers#

These do a whole chunk in one call, in Java:

luau
chunk:carve(caves, 0.8, { maxY = 70, crust = 6 })
chunk:ores { block = "iron_ore", count = 8, size = 8, minY = -20, maxY = 64 }
chunk:surface {
    { above = 160, top = "snow_block", under = "snow_block", depth = 2 },
    { steep = 4, top = "stone" },
    { wet = true, top = "sand", under = "sand", depth = 3 },
    { biome = "desert", top = "sand", under = "sandstone", depth = 4 },
    { top = "grass_block", under = "dirt", depth = 3 },
}
chunk:water(62)
  • carve(noise, threshold, options?) turns the blocks where a 3D noise is above threshold into air. A ridged noise above 0.8 makes long tunnels; a smooth noise above 0.5 makes round caverns. Options: minY, maxY, crust (leave that many blocks under the surface untouched, so caves do not open everywhere), with (a block to fill with instead of air, such as "lava" deep down), keep (blocks never carved) and step (the sample spacing, 4 by default). carve also takes a density, see Densities.
  • ores(options) scatters veins: block, into (the block or list of blocks it replaces, stone by default), count per chunk (fractions are chances), size in blocks (at most 48), minY, maxY. Veins that start in the next chunk carry over the border, so they are never cut in half.
  • surface(rules) repaints the top of each column. Each rule may set top, the block on the surface, and under with depth, the blocks below it. The first rule whose conditions all hold is used: above and below (the surface's height), steep (at least that many blocks of rise to a neighbour), wet (under water or not) and biome. A rule with no top leaves the column as it is, which is how { steep = 4 } keeps cliffs bare. A single rule can be passed without the list.
  • water(level, block?) fills the air from level down to the ground in every column. Caves under the ground stay dry. block is "water" by default; "lava" makes a lava sea.

Biomes#

A biome is set per column, or per 4 × 4 × 4 cell with a height, and uses Minecraft's biome names, which is what colours grass, leaves and water:

luau
chunk:biome(x, z, "desert")
chunk:biome(x, y, z, "lush_caves")
chunk:biomeAt(x, z)             -- "minecraft:desert"

To pick a biome for every column at once, give biomes a density that does not depend on y and a list of { upTo, name } steps. Each column takes the first step its value is below:

luau
local climate = density.noise(noise.new { seed = 5, scale = 800 })
chunk:biomes(climate, { { -0.3, "snowy_plains" }, { 0.3, "plains" }, { math.huge, "desert" } })

A column nobody sets is plains. Surface rules can match a biome, so set biomes before calling surface.

To blend two kinds of land, blend their heights with a noise before you shape them, and pick the biome from the same noise:

luau
local climate = noise.new { seed = 5, scale = 800, range = { 0, 1 } }
local mix = density.noise(climate):smoothstep(0.45, 0.55)
local height = density.lerp(mix, plainsHeight, desertHeight)

Decorating#

decorate is for anything that grows out of the ground and may lean into the next chunk: trees, boulders, bushes, a well. It runs once the chunk and all eight around it have their terrain, so a tree at the edge sees the ground on both sides.

luau
decorate = function(chunk)
    for _, p in chunk:points(6) do
        local y = chunk:height(p.x, p.z)
        if chunk:get(p.x, y, p.z) == GRASS then tree(chunk, p.x, y, p.z, generation.random(p.seed)) end
    end
end
  • chunk:points(spacing, salt?, reach?) scatters points about spacing apart, one per cell of a grid, and returns the ones inside the chunk as { x, z, seed }. The same world position gets the same point whichever chunk asks, which is what keeps them from bunching at borders. Different salt values give different scatters.
  • A decoration may reach 16 blocks past the chunk. Anything larger belongs in terrain as a structure.

Structures across chunks#

Buildings too large for decorate are built from the terrain stage, by every chunk they touch: each chunk works out which structures overlap it and stamps only its own part. Nothing waits on another chunk, and every piece lines up because each chunk computes the same structure.

luau
local SPACING, REACH = 200, 48

terrain = function(chunk)
    -- ...the ground...
    for _, site in chunk:points(SPACING, 7, REACH) do
        local rng = generation.random(site.seed)
        local y = math.floor(height:get(site.x, site.z))
        chunk:stamp(tower, site.x, y, site.z, rng:int(0, 3))
    end
end
  • reach widens the search: points up to reach blocks outside the chunk are returned too, so a structure 48 blocks wide is found by every chunk it overlaps.
  • Everything about a structure must come from its point: its seed, and the ground height from a density's get or a noise, never from chunk:height, which the other chunks cannot see.
  • generation.points(spacing, salt, x0, z0, x1, z1) gives the same points for any area, for when a structure needs its neighbours.

The desert example below flattens the ground around each village and joins the houses with paths this way.

Prefabs#

A prefab is a piece of blocks built once and stamped many times:

luau
local hut = prefab.new()
hut:fill(-2, 0, -2, 2, 3, 2, "oak_planks")
hut:fill(-1, 0, -1, 1, 2, 1, "air")
hut:set(0, 0, -2, "air")

chunk:stamp(hut, x, y, z, turns)
  • Positions in a prefab are relative to its origin, which lands on the x, y, z given to stamp.
  • turns rotates it by quarter turns around the origin, 0 to 3.
  • Air in a prefab is written, so a prefab can hollow out the ground. Cells never set are left alone.
  • stamp clips to what the chunk may write, so it is safe from terrain and from decorate.

Maps from pictures#

image(path) reads a picture from the place, which lets you paint a world by hand:

luau
local map = image "res://maps/island.png"
local w, h = map:size()
local height = map:sample(x + w / 2, z + h / 2) * 120      -- brightness from 0 to 1, blended between pixels
local r, g, b = map:color(x, z)                              -- each 0 to 1

Coordinates are in pixels. Points outside the picture take its nearest edge.

Randomness and seeds#

  • chunk:random(salt?) is a random generator for this chunk.
  • generation.random(seed) is a random generator from any number, such as a point's seed.
  • Both have :number(min?, max?), :int(min, max) and :chance(p).
  • generation.hash(x, y, z?) is a number from a position and the world seed.
  • Every noise is mixed with the world seed, so changing seed in place.toml changes the world.

Generated and built terrain#

A scene can have terrain of its own, the .polar file you build in the editor. terrain in [generation] decides how the two share the level:

  • "replace" (the default): a chunk the scene's terrain has comes from it alone; every other chunk is generated. Build a town by hand in the middle of an endless generated world.
  • "merge": every chunk is generated, then the scene's blocks are placed on top.
  • "ignore": only the generator is used.

In the editor#

  • Saving the generator regenerates the world. Every loaded chunk around you is built again from the new script, nearest first, while you keep working. The same happens when a module or picture the generator uses changes.
  • File > Regenerate Terrain does the same on demand.
  • File > Bake Generated Terrain writes every generated chunk that is loaded into the scene's terrain file. Those chunks then come from the file, so you can edit them by hand like any built terrain and they no longer change when the generator does. With terrain = "replace", the rest of the world keeps generating around them.
  • Errors in the generator appear in the output panel once each, and the chunks they break stay empty until you fix the script.

Speed#

Generation runs on Minecraft's worker threads, one generator VM per thread, and never on the main thread. What costs time is Luau doing per-block work, so:

  • Build the ground with columns, shape and densities, not set in a loop. A density is compiled once and runs as tight loops over whole chunks.
  • Use the layers (carve, surface, water, ores, replace) for whole-chunk work.
  • Keep set, get and per-block loops for small things: a tree, a hut, a path.

A place's generation cost is shown per step in the place inspector.

Examples#

Rolling hills#

Heights from two noises through columns, ridged tunnels that stay under a crust, sand at the shore, oak trees thinned by a forest noise, and a biome from the same noise so the grass shades change with the trees.

luau
-- generation/hills.luau
local ground = noise.new { scale = 220, octaves = 5, range = { 52, 94 } }
local detail = noise.new { seed = 1, scale = 40, octaves = 2, range = { -3, 3 } }
local caves = noise.new { seed = 2, kind = "perlin", scale = 60, fractal = "ridged", octaves = 2 }
local forest = noise.new { seed = 3, scale = 160 }
local woods = density.noise(forest)

local STONE, DIRT, GRASS = block "stone", block "dirt", block "grass_block"
local SAND, WATER = block "sand", block "water"
local LOG, LEAVES = block "oak_log", block "oak_leaves[persistent=true]"
local FLOWERS = { block "dandelion", block "poppy", block "cornflower", block "short_grass", block "short_grass" }
local SEA = 62
local height = density.noise(ground) + density.noise(detail)

local function tree(chunk, x, y, z, rng)
    local trunk = rng:int(4, 6)
    for dy = 1, trunk do chunk:set(x, y + dy, z, LOG) end
    for dy = trunk - 2, trunk + 1 do
        local r = if dy > trunk - 1 then 1 else 2
        for dx = -r, r do
            for dz = -r, r do
                if math.abs(dx) + math.abs(dz) <= r + 1 and chunk:get(x + dx, y + dy, z + dz) == 0 then
                    chunk:set(x + dx, y + dy, z + dz, LEAVES)
                end
            end
        end
    end
end

return {
    terrain = function(chunk)
        chunk:columns(height, STONE)
        chunk:biomes(woods, { { 0.1, "plains" }, { math.huge, "forest" } })
        chunk:carve(caves, 0.8, { maxY = 70, crust = 6 })
        chunk:ores { block = "coal_ore", count = 12, size = 10, maxY = 90 }
        chunk:ores { block = "iron_ore", count = 6, size = 6, maxY = 50 }
        chunk:surface {
            { wet = true, top = SAND, under = SAND, depth = 3 },
            { below = SEA + 2, top = SAND, under = SAND, depth = 3 },
            { top = GRASS, under = DIRT, depth = 3 },
        }
        chunk:water(SEA, WATER)
    end,

    decorate = function(chunk)
        local rng = chunk:random()
        for _, p in chunk:points(5) do
            local y = chunk:height(p.x, p.z)
            if chunk:get(p.x, y, p.z) == GRASS and rng:chance(math.clamp(woods:get(p.x, p.z) * 1.4, 0.03, 0.85)) then
                tree(chunk, p.x, y, p.z, rng)
            end
        end
        for _ = 1, 8 do
            local x, z = chunk.x + rng:int(0, 15), chunk.z + rng:int(0, 15)
            local y = chunk:height(x, z)
            if chunk:get(x, y, z) == GRASS then chunk:set(x, y + 1, z, FLOWERS[rng:int(1, #FLOWERS)]) end
        end
    end,
}