---
name: roblox-terrain
description: Use when generating, editing or reading Roblox smooth terrain from Luau (FillBlock, FillBall, FillCylinder, FillWedge, ReplaceMaterial, ReadVoxels/WriteVoxels, ReadVoxelChannels/WriteVoxelChannels), tuning terrain water or material colors, or using custom terrain materials (Expanded Terrain, material slots, MaterialVariants on terrain).
---

# Terrain from code

`workspace.Terrain` is a grid of 4x4x4-stud voxels. Each voxel has a **material** and an **occupancy** from 0
(empty) to 1 (full). Partial occupancy is what makes terrain look smooth. The editable area spans -128,000 to
128,000 studs on each axis (`Terrain.MaxExtents`). Terrain edits made on the server replicate to clients; edits
made on a client stay on that client.

## When to use it

- Landscapes, caves, cliffs, beaches, lakes and rivers.
- Procedural worlds (noise heightmaps, biomes, destructible ground).
- Large natural areas: terrain is far cheaper than thousands of parts and streams well.

Use parts or meshes for buildings, roads with hard edges and anything that needs exact geometry.

## Shapes

```luau
local Terrain = workspace.Terrain

Terrain:FillBlock(CFrame.new(0, -10, 0), Vector3.new(512, 20, 512), Enum.Material.Grass)
Terrain:FillBall(Vector3.new(0, 10, 0), 24, Enum.Material.Rock)
Terrain:FillCylinder(CFrame.new(60, 0, 0), 40, 12, Enum.Material.Sandstone) -- cframe, height, radius
Terrain:FillWedge(CFrame.new(-60, 5, 0), Vector3.new(20, 10, 30), Enum.Material.Ground) -- same orientation as a WedgePart

-- Carve: fill with Air
Terrain:FillBall(Vector3.new(0, 10, 0), 10, Enum.Material.Air)

-- Water: fill with Water (it sits in empty space and around solids)
Terrain:FillBlock(CFrame.new(0, -2, 200), Vector3.new(300, 8, 300), Enum.Material.Water)

Terrain:Clear() -- removes all terrain
```

All Fill methods overwrite what was in the volume. `FillBlock` and `FillWedge` take a rotated `CFrame`.
Shapes smaller than a voxel (4 studs) get partial occupancy or vanish, so keep features at least 4 studs thick.

## Replacing materials

`ReplaceMaterial` and every voxel method need a region **aligned to the 4-stud grid** and `resolution` exactly `4`.
Align with `Region3:ExpandToGrid(4)`.

```luau
local region = Region3.new(Vector3.new(-256, -64, -256), Vector3.new(256, 64, 256)):ExpandToGrid(4)
Terrain:ReplaceMaterial(region, 4, Enum.Material.Grass, Enum.Material.Snow)
```

## Voxel read and write

`ReadVoxels` returns two 3D arrays `[x][y][z]` (materials and occupancies), each with a `Size` field.
`WriteVoxels(region, 4, materials, occupancy)` writes them back; the arrays must exactly match the region size in
voxels. One call can touch at most **4,194,304 voxels**.

`ReadVoxelChannels` and `WriteVoxelChannels` work per channel: `SolidMaterial`, `SolidOccupancy` and
`LiquidOccupancy`. Water is not a solid material in channels: write a water voxel as `SolidMaterial = Air` plus
`LiquidOccupancy` between 0 and 1. Write only the channels you change.

Heightmap generation, one chunk per call:

```luau
local Terrain = workspace.Terrain
local RES = 4

local function generateChunk(originX: number, originZ: number, widthVoxels: number, heightVoxels: number)
	local region = Region3.new(
		Vector3.new(originX, 0, originZ),
		Vector3.new(originX + widthVoxels * RES, heightVoxels * RES, originZ + widthVoxels * RES)
	):ExpandToGrid(RES)
	local size = region.Size / RES
	local sx, sy, sz = math.round(size.X), math.round(size.Y), math.round(size.Z)
	local minCorner = region.CFrame.Position - region.Size / 2

	local materials, occupancy = table.create(sx), table.create(sx)
	for x = 1, sx do
		materials[x], occupancy[x] = table.create(sy), table.create(sy)
		for y = 1, sy do
			materials[x][y], occupancy[x][y] = table.create(sz), table.create(sz)
		end
		for z = 1, sz do
			local wx = minCorner.X + (x - 0.5) * RES
			local wz = minCorner.Z + (z - 0.5) * RES
			local groundY = 20 + math.noise(wx / 200, wz / 200) * 30 -- surface height in studs
			for y = 1, sy do
				local cellBottom = minCorner.Y + (y - 1) * RES
				local fill = math.clamp((groundY - cellBottom) / RES, 0, 1)
				occupancy[x][y][z] = fill
				materials[x][y][z] = if fill <= 0 then Enum.Material.Air
					elseif groundY > 28 then Enum.Material.Rock
					else Enum.Material.Grass
			end
		end
	end
	Terrain:WriteVoxels(region, RES, materials, occupancy)
end

for cx = -4, 3 do
	for cz = -4, 3 do
		generateChunk(cx * 256, cz * 256, 64, 32) -- 64 x 32 x 64 voxels per call
		task.wait() -- let the engine breathe between chunks
	end
end
```

## Water and material colors

Water appearance is global, on `Terrain`:

| Property | Range |
|---|---|
| `WaterColor` | Color3 tint |
| `WaterTransparency` | 0 (opaque) to 1 (clear), default 0.3 |
| `WaterReflectance` | 0 to 1, default 1 |
| `WaterWaveSize` | 0 to 1 stud |
| `WaterWaveSpeed` | 0 to 100 waves per minute |

```luau
Terrain.WaterColor = Color3.fromRGB(20, 90, 110)
Terrain.WaterTransparency = 0.6
Terrain:SetMaterialColor(Enum.Material.Grass, Color3.fromRGB(90, 140, 60))
print(Terrain:GetMaterialColor(Enum.Material.Grass))
```

`Terrain.Decoration` (animated grass) and `Terrain.GrassLength` (0.1 to 1) are not scriptable: set them in the
Properties window.

## Custom terrain materials (Expanded Terrain)

Fully launched in October 2026. A place can define up to **62 editable terrain materials**, each a base material
plus an optional MaterialVariant and a color tint, so you can have many shades of grass or several rock types.

Turn it on (Studio only, per place): select **Workspace**, set **Expanded Terrain** to **Enabled**, save, close and
reopen the place. In Team Create, exit and wait about 15 seconds before reopening. Places edited during the early
access program must still do this or their custom materials show up as grass.

Manage materials in the **Terrain Material Manager** (Window > 3D, or right-click a material in the terrain editor's
material picker). Editing a material updates all terrain that uses it. Deleting a material that terrain still uses
turns that terrain bright purple.

Slots from script: slot `0` is Air and `1` is Water (read-only), `2` to `22` are the default materials, `23` to `63`
start empty (`GetMaterialSlot` returns nil). Write slot indices with the `SolidMaterialIndex` channel.

```luau
-- "SwampGrass" must be a MaterialVariant in MaterialService with BaseMaterial = Grass
Terrain:SetMaterialSlot(23, Enum.Material.Grass, "SwampGrass", Color3.fromRGB(50, 60, 30))
Terrain:SetMaterialSlot(24, Enum.Material.Rock, "", Color3.fromRGB(120, 110, 100)) -- tinted default rock

local base, variant, color = Terrain:GetMaterialSlot(23)

local region = Region3.new(Vector3.new(0, 0, 0), Vector3.new(64, 16, 64)):ExpandToGrid(4)
local size = region.Size / 4
local sx, sy, sz = math.round(size.X), math.round(size.Y), math.round(size.Z)
local indices, occupancy = {}, {}
for x = 1, sx do
	indices[x], occupancy[x] = {}, {}
	for y = 1, sy do
		indices[x][y], occupancy[x][y] = {}, {}
		for z = 1, sz do
			indices[x][y][z] = if (x + z) % 2 == 0 then 23 else 24
			occupancy[x][y][z] = 1
		end
	end
end
Terrain:WriteVoxelChannels(region, 4, { SolidMaterialIndex = indices, Occupancy = occupancy })

Terrain:ResetMaterialSlot(24) -- voxels still using slot 24 now render as invalid
```

The `Fill*` methods and `ReplaceMaterial` take an `Enum.Material`, so use `WriteVoxelChannels` with slot indices
to place custom materials from code. Read them back with `ReadVoxelChannels(region, 4, { "SolidMaterialIndex" })`.

Known limits at launch: a MaterialVariant's `CustomPhysicalProperties` are not applied to terrain slots yet;
terrain thinner than 16 voxels can disappear on clients when streaming with a small radius (make terrain thicker);
heightmap and colormap import may give different results than before.

## MaterialVariants on terrain

A `MaterialVariant` in `MaterialService` with a terrain `BaseMaterial` (Grass, Rock, Sand...) can texture terrain.
Add up to three `TerrainDetail` children (`Face` = `Top`, `Side`, `Bottom`) to give, for example, grassy tops and
rocky sides. `MaterialService:SetBaseMaterialOverride(Enum.Material.Grass, "SwampGrass")` replaces the built-in
material everywhere; prefer terrain slots when you only want some areas to change. A variant's texture properties
can only be set in Studio or by plugins.

## Announced, not released

At RDC 2026 Roblox announced terrain scattering, path splines, projected terrain decals, virtual texturing and
SDF terrain, planned for the Terrain Early Access Program. They are not available as of October 2026; don't write
code for them.

## Mistakes to avoid

- Passing an unaligned region or a resolution other than 4 to voxel methods or `ReplaceMaterial` (it throws).
- One giant `WriteVoxels` over the whole map. Stay under 4,194,304 voxels per call and `task.wait()` between chunks.
- Building terrain one voxel at a time with `FillBlock` in a loop. Batch with `WriteVoxels`.
- Writing `Enum.Material.Water` into the `SolidMaterial` channel. Use `LiquidOccupancy`.
- Terrain features thinner than 4 studs (they disappear) or thin shells with Expanded Terrain and streaming.
- Calling deprecated legacy methods (`SetCell`, `GetCell`, `SetCells`, `AutowedgeCell`, `ConvertToSmooth`).
- Editing terrain on the client and expecting other players or the server to see it.
- Forgetting to set Expanded Terrain to Enabled before relying on slots 23 to 63.
