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:
[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:
-- 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
terrainfunction with nodecorate.
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:
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) -- 3Dfbmadds octaves;ridgedfolds each one so its middle becomes a sharp ridge, the shape of mountain crests and tunnels;billowfolds the other way into round lumps, the shape of dunes and clouds.- Setting
octavesabove 1 without afractalmeansfbm. warpmoves each point by another noise before sampling, which turns round blobs into flowing, twisted shapes.warp = 30is 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:
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:
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 f32noise 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:
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")
enddensity.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:
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)ord: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.
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:
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 abovethresholdinto air. Aridgednoise 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) andstep(the sample spacing, 4 by default).carvealso takes a density, see Densities.ores(options)scatters veins:block,into(the block or list of blocks it replaces, stone by default),countper chunk (fractions are chances),sizein 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 settop, the block on the surface, andunderwithdepth, the blocks below it. The first rule whose conditions all hold is used:aboveandbelow(the surface's height),steep(at least that many blocks of rise to a neighbour),wet(under water or not) andbiome. A rule with notopleaves 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 fromleveldown to the ground in every column. Caves under the ground stay dry.blockis"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:
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:
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:
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.
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
endchunk:points(spacing, salt?, reach?)scatters points aboutspacingapart, 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. Differentsaltvalues give different scatters.- A decoration may reach 16 blocks past the chunk. Anything larger belongs in
terrainas 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.
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
endreachwidens the search: points up toreachblocks 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'sgetor a noise, never fromchunk: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:
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, zgiven tostamp. turnsrotates 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.
stampclips to what the chunk may write, so it is safe fromterrainand fromdecorate.
Maps from pictures#
image(path) reads a picture from the place, which lets you paint a world by hand:
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 1Coordinates 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'sseed.- 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
seedinplace.tomlchanges 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,shapeand densities, notsetin 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,getand 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.
-- 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,
}