Skip to content

Terrain and blocks

Two separate systems that live alongside each other.

world.terrainworld.blocks
Shapesmooth reliefone-metre cubes
Good forhills, valleys, riverbeds, building padswalls, mines, structures, anything with a face
Coordinatesworld metreswhole block coordinates
Readingfree, publicfree, public

Putting something on the ground

luau
local ground = world.terrain:height(x, z)
world:spawn({ model = "crate", pos = { x = x, y = ground + 0.5, z = z } })

height never fails: a flat world reads 0, and so does nonsense input, so you can use it straight in maths.

It answers for the terrain, not for whatever is standing on it. For "what is under this point, including objects", cast a ray downward:

luau
local hit = world:raycast({
    from = { x = x, y = 60, z = z },
    dir = { x = 0, y = -1, z = 0 },
})

Digging with the brushes

Four brushes, all with the same shape:

luau
world.terrain:raise(x, z, radius, strength)
world.terrain:lower(x, z, radius, strength)
world.terrain:flatten(x, z, radius, strength)
world.terrain:smooth(x, z, radius, strength)

radius defaults to 4 (range 0.5–64), strength to 1 (range 0–8).

flatten pulls the area toward the height at the brush centre, not toward zero — put the centre where you want the floor.

A crater

luau
local function crater(cx: number, cz: number)
    world.terrain:lower(cx, cz, 8, 4)
    world.terrain:raise(cx, cz, 12, 0.6)     -- the rim thrown up
    world.terrain:smooth(cx, cz, 14, 1)      -- soften the join
end

Three strokes is three effects. A hundred strokes is not — spread those over a timer:

luau
local queue = {}
local i = 0

self:on_timer(0.1, function()
    for _ = 1, 4 do
        i += 1
        local s = queue[i]
        if not s then return end
        world.terrain:raise(s.x, s.z, s.r, s.h)
    end
end)

Blocks: always look the id up by name

luau
local STONE = world.blocks:find("Stone")
if not STONE then
    print("this world has no Stone in its palette")
    return
end

Never write the number. The ids belong to the creator's palette. Reordering it re-points every hard-coded id at a different material, silently, and the world starts building itself out of glass. A name is the one handle a script and a person share.

Reading a cell

luau
world.blocks:get(x, y, z)      -- the type id; 0 is air, and so is outside the world
world.blocks:solid(x, y, z)    -- can a body walk through it?
world.blocks:name(id)          -- the other direction

solid is not the same as get() ~= 0 — a tuft of grass is a block you walk through. Use solid for "can I stand here", get for "what is here".

Breaking and placing what you are looking at

luau
self:on_action("mine", { key = "e" }, function(p, shot)
    local eye, dir = shot.eye, shot.dir
    local hit = world.blocks:raycast(eye.x, eye.y, eye.z, dir.x, dir.y, dir.z, 6)
    if not hit then return end

    local kind = world.blocks:name(hit.block) or "something"
    world.blocks:set(hit.x, hit.y, hit.z, nil)

    print(p.name .. " broke " .. kind)
end)

Building on the face you hit uses the place_* fields — the same cell a player's own right click would use:

luau
world.blocks:set(hit.place_x, hit.place_y, hit.place_z, STONE)

This raycast takes loose numbers, not a table. It is the one call in the API that does: origin and direction are six arguments, not { from =, dir = }.

Building a structure

luau
--!strict
local self = require("self")
local world = require("world")

local function room(x: number, y: number, z: number, w: number, h: number, d: number, b: number)
    world.blocks:fill(x, y, z, x + w, y, z + d, b)                  -- floor
    world.blocks:fill(x, y + h, z, x + w, y + h, z + d, b)          -- roof
    world.blocks:fill(x, y, z, x + w, y + h, z, b)                  -- walls
    world.blocks:fill(x, y, z + d, x + w, y + h, z + d, b)
    world.blocks:fill(x, y, z, x, y + h, z + d, b)
    world.blocks:fill(x + w, y, z, x + w, y + h, z + d, b)
end

world:on_start(function()
    local STONE = world.blocks:find("Stone")
    if STONE then
        room(0, 0, 0, 8, 4, 10, STONE)
    end
end)

Six fills is six effects — inside the ceiling. A fill that covers too many cells is refused whole, with nil, "box too big", said now rather than done halfway.

A pillar that grows one block per click

luau
self:on_touch(function()
    local here = self:position()
    local bx, bz = math.floor(here.x), math.floor(here.z)

    for y = math.floor(here.y), math.floor(here.y) + 12 do
        if world.blocks:get(bx, y, bz) == 0 then
            world.blocks:set(bx, y, bz, STONE)
            return
        end
    end
end)

The loop only reads, which is free. It places exactly one block, so the whole click costs one effect.

Reacting to what players do

luau
local events = require("events")

events:listen("world.block", function(e)
    -- somebody placed or broke a block
end)

previous arrives as a number — world.blocks:name(previous) is what turns it into a sentence.

Permissions

Writing terrain in a social world is the world owner's scripts only; in an experience, any script. Writing blocks needs the block permission in either.

Reading both is always free and always public — everyone standing there is already looking at it.

See also

Hungrit scripting documentation.