We use cookies on this site to enhance your user experience

Scripting Terrain

Scripting Terrain

Jul 02 2018, 2:31 PM PST 10 min


To understand how Terrain works, it is important to know a few of the fundamental concepts behind it. Firstly, Terrain is based on a voxel system. A voxel is basically a point in space, often arranged in a grid. In Roblox, each cell in the voxel grid measures 4x4x4 studs.

To create the terrain effect, points in the voxel grid are assigned a material. This material is then filled in around the voxel to create the terrain. With Roblox’s legacy terrain, the material was shaped into a cube around the voxel (or sometimes a 45 degree slope). With Smooth Terrain however, this is not the case. With Smooth Terrain each voxel now contains an occupancy value along with its material value. This occupancy value defines how full the voxel is with the given material. This value can be anywhere between 0 (almost completely empty) to 1 (very full, sometimes overflowing). When Roblox generates terrain based on these values, the shape of the terrain is organically generated to create smooth curves to accommodate varying occupancy values.


The Terrain/FillBlock function can be used to create terrain from the volume defined by a Part. This can be used to quickly create terrain by simply placing parts where you want the terrain to go. When the terrain is generated the volume will be filled as close as possible by adjusting the occupancy of the voxels the volume overlaps. This is not guaranteed to get a perfect match to the volume but will be the approximate shape. This function takes several parameters:

  • Articles/Understanding CFrame|CFrame: The CFrame of the region you want to fill. As with all CFrames, this can define both a position and an orientation.
  • Vector3 size: The size of the region to be filled. If the CFrame is rotated the size will be rotated accordingly.
  • Enum/Material material: The material to be used when generating the terrain.


Given a Part in the Workspace called Part, the following will generate grass terrain to fill the volume of the part.

game.Workspace.Terrain:FillBlock(game.Workspace.Part.CFrame, game.Workspace.Part.Size, Enum.Material.Grass)



The Terrain/FillBall function creates terrain to fill a spherical volume. This function takes several parameters:

  • Vector3 center: The center of the sphere to be filled.
  • float radius: The radius of the sphere.
  • Enum/Material material: The material to be used when generating the terrain.

This will create a sphere of sand above the center of a place:

game.Workspace.Terrain:FillBall(Vector3.new(0, 100, 0), 50, Enum.Material.Sand)



The Terrain/FillRegion function creates terrain within a defined Region3.

  • Region3 region: The region to be filled.
  • float resolution: The resolution of voxels to fill. Must be set at 4. See .
  • Enum/Material material: The material to be used when generating the terrain.


This will create slate terrain in the region specified. Note that the region is not initially aligned to the voxel grid but the ExpandToGrid function is used to fix that.

local region = Region3.new(Vector3.new(0,0,-3), Vector3.new(4,4,4))
region = region:ExpandToGrid(4)
game.Workspace.Terrain:FillRegion(region, 4, Enum.Material.Slate)


Reading and writing voxels

The functions outlined above do a lot of work to automatically calculate which voxels to fill with terrain and how much occupancy to use. Sometimes you may want to directly read and write to specific voxels to specify the material and occupancy.


Terrain/ReadVoxels takes a Region3 and a resolution and returns the raw voxel data for the region specified. This data is returned as two 3D arrays. The first array contains material values, the second contains occupancy. Both arrays also have a Vector3 property called Size that can be used to determine the size of the arrays in each of their dimensions. As with FillRegion the region supplied must be aligned to the voxel grid. ExpandToGrid can be used to ensure the correct region is used.

local region = Region3.new(Vector3.new(0,0,-15), Vector3.new(4,8,4))
region = region:ExpandToGrid(4)
local material, occupancy = game.Workspace.Terrain:ReadVoxels(region, 4)
local size = material.Size
for x = 1, size.X do
	for y = 1, size.Y do
		for z = 1, size.Z do
			print("Material at (", x, y, z, "): ", material[x][y][z])
			print("Occupancy at (", x, y, z, "): ", occupancy[x][y][z])


Terrain/WriteVoxels allows you to specify specific material and occupancy values for each voxel in a region. Like ReadVoxels and FillRegion the region specified must be aligned to the voxel grid using a method like ExpandToGrid. The material and occupancy arrays must also be the correct size for this function to work properly.

local region = Region3.new(Vector3.new(0,0,0), Vector3.new(4,4,16))
region = region:ExpandToGrid(4)

local function create3dTable(size)
	local ret = {}
	for x = 1, size.X do
		ret[x] = {}
		for y = 1, size.Y do
			ret[x][y] = {}
	return ret

local material = create3dTable(Vector3.new(1,1,4))
material[1][1][1] = Enum.Material.Sand
material[1][1][2] = Enum.Material.Sand
material[1][1][3] = Enum.Material.Grass
material[1][1][4] = Enum.Material.Grass

local occupancy = create3dTable(Vector3.new(1,1,4))
occupancy[1][1][1] = .25
occupancy[1][1][2] = .5
occupancy[1][1][3] = .75
occupancy[1][1][4] = 1

game.Workspace.Terrain:WriteVoxels(region, 4, material, occupancy)



The functions Terrain/FillRegion, Terrain/ReadVoxels, and Terrain/WriteVoxels all require a parameter to define the resolution of the voxels the functions are intended to work with. At the moment this must always be set to 4, as Roblox voxels are currently 4x4x4 studs in size. This setting however is left as a variable as Roblox may implement a smaller voxel resolution at some point. Leaving this as a variable allows code written with the current system to work expecting 4x4x4 voxels to work with any size voxel in the future.


Flood Fill

This LocalScript will flood fill depressions in terrain with water. If the algorithm detects that the fill could potentially go on forever, it will abort the process and not generate any new terrain.

local mouse = game.Players.LocalPlayer:GetMouse()
local terrain = game.Workspace.Terrain
local function checkDirection(origin, direction)
	local ray = Ray.new(origin, direction)
	local part, point, normal = game.Workspace:FindPartOnRay(ray, nil, true)
	return part
local function processDirection(queue, processed, current, direction)
	local nextNode = current + direction
	if not processed[tostring(nextNode)] then
		table.insert(queue, nextNode)
local function floodFill(voxelPos)
	local region = Region3.new(voxelPos - Vector3.new(2,2,2), voxelPos + Vector3.new(2,2,2))
	local material, occupancy = terrain:ReadVoxels(region, 4)
	if material[1][1][1] ~= Enum.Material.Air then
		print("Could not fill from that voxel: Voxel not empty")
	local queue = {}
	local processed = {}
	local success = true
	table.insert(queue, voxelPos)
	while #queue > 0 do
		local current = table.remove(queue)
		region = Region3.new(current - Vector3.new(2,2,2), current + Vector3.new(2,2,2))
		material, occupancy = terrain:ReadVoxels(region, 4)
		if material[1][1][1] == Enum.Material.Air then
			if  checkDirection(current, Vector3.new(1000, 0, 0)) and
				checkDirection(current, Vector3.new(-1000, 0, 0)) and
				checkDirection(current, Vector3.new(0, 0, 1000)) and
				checkDirection(current, Vector3.new(0, 0, -1000)) and
				checkDirection(current, Vector3.new(0, -1000, 0)) then			
				processed[tostring(current)] = current
				processDirection(queue, processed, current, Vector3.new(4,0,0))
				processDirection(queue, processed, current, Vector3.new(-4,0,0))
				processDirection(queue, processed, current, Vector3.new(0,0,4))
				processDirection(queue, processed, current, Vector3.new(0,0,-4))
				processDirection(queue, processed, current, Vector3.new(0,-4,0))
				processed = {}
				success = false
	if success then
		for _, position in pairs(processed) do
			region = Region3.new(position - Vector3.new(2,2,2), position + Vector3.new(2,2,2))
			material[1][1][1] = Enum.Material.Water
			occupancy[1][1][1] = 1
			terrain:WriteVoxels(region, 4, material, occupancy)
		print("Could not fill from that voxel: Would create infinite fill")
local function round(num)
	return math.floor(num + .5)
	local hit = mouse.Hit
	local x = round(mouse.Hit.p.X)
	x = x - x%4 + 2
	local y = round(mouse.Hit.p.Y)
	y = y - y%4 + 2
	local z = round(mouse.Hit.p.Z)
	z = z - z%4 + 2
	floodFill(Vector3.new(x, y, z))

Procedural terrain generation

This example generates terrain as the player walks through the world. This code generates a heightmap using math.noise and fills in the terrain using Terrain/FillBlock.

local Players = game:GetService("Players")


local BASE_HEIGHT 		= 10				-- The main height factor for the terrain.
local CHUNK_SCALE 		= 3 				-- The grid scale for terrain generation. Should be kept relatively low if used in real-time.
local RENDER_DISTANCE 	= 120 / 4 			-- The length/width of chunks in voxels that should be around the player at all times
local X_SCALE 			= 90 / 4			-- How much we should strech the X scale of the generation noise
local Z_SCALE 			= 90 / 4			-- How much we should strech the Z scale of the generation noise
local GENERATION_SEED	= math.random() 	-- Seed for determining the main height map of the terrain.
local chunks = {}
local function chunkExists(chunkX, chunkZ)
	if not chunks[chunkX] then
		chunks[chunkX] = {}
	return chunks[chunkX][chunkZ]
local function mountLayer(x, heightY, z, material)
	local beginY = -BASE_HEIGHT
	local endY = heightY
	local cframe = CFrame.new(x * 4 + 2, (beginY + endY) * 4 / 2, z * 4 + 2)
	local size = Vector3.new(4, (endY - beginY) * 4, 4)
	workspace.Terrain:FillBlock(cframe, size, material)	
function makeChunk(chunkX, chunkZ)
	local rootPosition = Vector3.new(chunkX * CHUNK_SCALE, 0, chunkZ * CHUNK_SCALE)
	chunks[chunkX][chunkZ] = true -- Acknowledge the chunk's existance.
	for x = 0, CHUNK_SCALE - 1 do
		for z = 0, CHUNK_SCALE - 1 do
			local cx = (chunkX * CHUNK_SCALE) + x
			local cz = (chunkZ * CHUNK_SCALE) + z
			local noise = math.noise(GENERATION_SEED, cx / X_SCALE, cz / Z_SCALE)
			local cy = noise * BASE_HEIGHT
			mountLayer(cx, cy, cz, Enum.Material.Grass)
function checkSurroundings(location)
	local chunkX, chunkZ = math.floor(location.X / 4 / CHUNK_SCALE), math.floor(location.Z / 4 / CHUNK_SCALE)
	local range = math.max(1, RENDER_DISTANCE / CHUNK_SCALE)
	for x = -range, range do
		for z = -range, range do
			local cx, cz = chunkX + x
			local cz = chunkZ + z
			if not chunkExists(cx, cz) then
				makeChunk(cx, cz)
while true do
	for _, player in pairs(Players:GetPlayers()) do
		if player.Character then
			local humanoidRootPart = player.Character:FindFirstChild("HumanoidRootPart")
			if humanoidRootPart then
  • procedural
  • terrain
  • coding