Skip to content

Building and survival

Letting players raise structures that hold each other up, and the rules of a world where that matters.

Turning building on

Three lines is a working set:

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

world:on_start(function()
    world.build:define("wall",  { from = "WoodWall",  kind = "wall",  material = "wood" })
    world.build:define("floor", { from = "WoodFloor", kind = "floor", material = "wood" })
    world.build:define("beam",  { from = "WoodBeam",  kind = "beam",  material = "wood" })
end)

players:on_enter(function(p)
    world.build:allow(p.id, { wall = -1, floor = -1, beam = -1 })
end)

from is the display name of an object you already built with the ordinary editor. The region snapshots its whole subtree — parts, properties, contents and scripts — so a door that opens keeps opening on every copy anybody raises, with the builder as its owner.

Keep prototypes somewhere out of the way, or delete them: the catalogue is saved with the world and outlives them.

The allowance is a count, not a recipe

luau
world.build:allow(id, { wall = 10, floor = 4, roof = 0 })
world.build:allow(id, { wall = -1 })    -- unlimited
world.build:allow(id, {})               -- closes the bar
CountThe player sees
-1the piece, no number
nthe piece, with n left
0the piece, greyed out — how somebody learns it exists before affording it
absentnothing; the piece is not in their bar

The engine does not know what wood is. It does not know what a wall costs or whether the player is holding a hammer — those are your game's questions, and every game answers them differently.

What the engine does is show the number and refuse the eleventh wall without a round trip. You decide when to hand out more.

Charging for a piece

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

local COST = { wall = 4, floor = 3, beam = 2 }

local function wood_of(id: string): number
    return tonumber(self.store:get("wood." .. id) or "0") or 0
end

local function allowance(id: string)
    local wood = wood_of(id)
    world.build:allow(id, {
        wall  = math.floor(wood / COST.wall),
        floor = math.floor(wood / COST.floor),
        beam  = math.floor(wood / COST.beam),
    })
end

events:listen("world.build", function(e)
    if type(e) ~= "table" then return end

    local id = tostring(e.player_uuid)
    local price = COST[tostring(e.piece)] or 0

    self.store:set("wood." .. id, tostring(math.max(0, wood_of(id) - price)))
    allowance(id)
end)

Re-issuing the whole allowance after each build is what keeps the bar honest: the numbers the player sees are always what they can actually afford.

What holds a structure up

Every standing piece carries a support from 1 (on the ground) to 0 (nothing holding it), computed from the joints.

Sideways costs more than upward. That single asymmetry is the whole mechanic: a stone tower goes up comfortably, a wooden walkway sags after a few spans and needs a pillar.

materialupout
"straw"4 pieces2
"wood" (default)85
"stone"168
"iron"3212

When support reaches zero the piece comes down, and so does whatever it was holding. Nobody can raise a piece that would fall immediately — the placement is refused.

Choosing materials is how you pace a world: straw is a shelter, wood is a house, stone is a keep. A player who wants a bridge has to find iron or build pillars.

Reacting to a collapse

luau
events:listen("world.collapse", function(e)
    -- a piece lost its support and came down
end)

Different from world.destroyed, which is about damage. A wall can do either, and they usually deserve different reactions — a collapse is a consequence of the structure, a destruction is somebody's doing.

Reading how strained something is

luau
local support, band = world.build:support(id)
BandSupportReads as
0≥ 0.75solid
1≥ 0.5fine
2≥ 0.25strained
3< 0.25about to go

The band travels so your HUD and the built-in support view can never disagree about what a colour means.

Placing a structure from a script

luau
world.build:place("wall", {
    pos = { x = 4, y = 0, z = -2 },
    rot = { x = 0, y = 90, z = 0 },
})

A starter shelter at spawn, a quest structure, a ruin the world begins with.

The structural rules still apply — a script cannot hang a wall in mid-air either, because the support solve would knock it down on the same tick. For something that should go anywhere at all, use world:spawn.

Resources that come back

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

local REGROW = 900

self:set({ max_health = 60 })

local function harvested_at(): number
    return tonumber(self.store:get("cut") or "0") or 0
end

self:on_touch(function(p)
    local cut = harvested_at()

    if cut > 0 then
        if world:clock() - cut < REGROW then
            return                          -- still bare
        end
        self.store:set("cut", "0")
        self:set({ transparency = 0, collide = true })
        return
    end

    events:emit("wood:credit", { who = p.id, amount = 8 })
    self.store:set("cut", tostring(world:clock()))
    self:set({ transparency = 0.85, collide = false })
end)

The timestamp is what makes this survive: world:clock() keeps running while the world is off, so a tree cut on Friday has grown back by Monday. That is the only version of time a visitor recognises.

Catching up after a long sleep

luau
world:on_wake(function(elapsed)
    local cycles = math.floor(elapsed / REGROW)
    -- arithmetic, never a loop: `elapsed` can be a month
end)

See also

Hungrit scripting documentation.