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).
Use the robloxvibecoders MCP: call get_resource with id "skill-roblox-terrain" and install it as a Claude Code skill in this project (.claude/skills/roblox-terrain/SKILL.md).
Skills in ~/.claude/skills work in every project. Put them in your project's .claude/skills folder to share them with your team.
What your AI reads
SKILL.md
---
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.