Log inGet started

Scenes, authored as code

A scene can say what it holds in code. build.luau inside the scene's folder is where the scene's content is written — all of it — and what that code creates is what the scene is made of: the entities…

Lobby.scene/
  build.luau       what the scene is made of
  scene.json       the entities, as the last build left them
  entrypoint.luau  what happens while the scene is played

The code runs while you author, and saving the scene bakes what it created into scene.json. That is what makes the entities durable content: a collaborator opening the world sees them, they replicate, and they load without build.luau running at all.

The builder is ordinary engine code

function content()
    for i = 0, 4 do
        local crate = entity.spawn("crate")
        crate.localPosition = { i * 2, 0.5, 0 }
        crate.component.add("Model", { model = asset.resolve("cube", "mesh") })
    end
end

Real loops, real conditionals, real locals, a require of a module that computes a layout — entity.spawn, component.add and transform writes are the same calls they are anywhere else.

What the builder's code can reach

Everything the engine offers a script anywhere else. The builder's chunk is compiled into its own environment, which is where inputs lives and is what gives a placement's params their meaning, and where build lives — the build's own surface, whose build.asset makes the assets the content names; every other name resolves through that environment to the engine's global one. entity, asset, Transform, math, require, vfs, task, tools, world, string, table, os, Field and NoSync read inside a builder as they read at the top level.

So Transform.quatFromAxisAngle(ax, ay, az, angle) is the same call in a builder that it is outside one, and require("@builtin::modules.json") loads the same module — reach for the engine's own maths and modules rather than rewriting them locally. A toolbox is reached the way it is everywhere: tools.use("wld", "mode").

The builder authors; components behave. A machine's frame, its housing and its colliders are construction, so they belong in the builder; the component that drives the machine is runtime logic, so the builder attaches it and it runs at play.

The two surfaces

function content()
    -- the scene's own entities. Present in play.
end

function editorOnly()
    -- present while authoring, absent in play
    local guide = entity.spawn("origin_marker")
    guide.component.add("Model", { model = asset.resolve("cube", "mesh") })
    guide.localScale = { 0.1, 4, 0.1 }
end

content() holds everything the scene is — whatever a player would find there, lighting and the player setup included. editorOnly() holds whatever should be present while authoring and absent in play; those entities carry the EditorOnly participation the runtime deactivates and hides when play begins, and they are baked too, so a collaborator opening the world sees the same ones. An alignment guide, a spacing marker or a debug volume is the shape that usually takes.

Either one on its own is a whole build. Both are declared as globals of build.luaufunction content() … end — which is the name the build reads them by; a build.luau that declares neither says which two it looked for. A scene with no build.luau has no build at all.

Editing the file rebuilds the scene

Writing build.luau is the signal — save the file and the loaded scene is rebuilt in place. An entity keeps the id it had, so a reference to it survives: a spawn naming a prototype, a component field naming another entity, a selection in the editor. Add a sixth crate to the loop above and the other five stay exactly where they are.

The build writes its own name and each entity's place in the hierarchy onto the entity, and scene.json records that pair — which is why a rebuild in a later session, after a reload, lands on exactly the entities the first build placed without anything having been remembered in between.

The write returns as soon as the file is on disk, and the rebuild finishes behind it. What it landed comes back as a notice naming the scene and the count each surface placed:

[info] a write to build.luau rebuilt the scene { content="5", scene="scenes.main",
editorOnly="1" }

A big scene takes seconds to rebuild, and an edit saved during one of those is an edit to code the rebuild has already read. It gets a rebuild of its own once that one finishes, and says which it was:

[info] a write to build.luau arrived during a rebuild and was rebuilt after it
{ content="6", scene="scenes.main", editorOnly="1" }

Several edits saved during the same rebuild get the one run after it, which reads the file as it stands then — so it carries all of them.

A scene that declares a build also runs it when it loads in edit, so the scene shows what the code says now, including an edit made while the world was closed. Play mode never runs it — in play the entities are loaded from scene.json as authored content.

When the file did not change

:build() runs the build against what it resolves right now — for the case where something the builder reads changed and the file did not: a module it requires, an asset it resolves, a value it computes from elsewhere in the world. After a write it is unnecessary; the write has already run it.

layers.active.asset:build()                  -- rebuild and save
layers.active.asset:build({ save = false })  -- rebuild, leave the result unsaved

It answers with how many entities each surface placed — { content = 5, editorOnly = 1 } for the two above — and with nil for a scene that has no build.luau. Called while a build for that scene is already running, it comes back at once with that build instead of starting a second one:

{ inFlight = true, scene = "/zero/source/scenes/main.scene", trigger = "write",
  startedBy = "a write to build.luau",
  message = "a build of /zero/source/scenes/main.scene started by a write to
             build.luau is running now — this call answered with it and started
             no second build" }

inFlight is what tells the two answers apart.

What a rebuild touches

Only the entities the build itself placed. Anything a human dragged in, or a tool placed directly, is invisible to a rebuild and is never moved or removed.

Within what it owns the build is authoritative: an entity the code stopped emitting is despawned, a component it stopped adding is removed, an attribute it stopped setting is dropped. Everything else is a diff — an entity whose record already matches is left exactly as it is, so a rebuild that changed nothing changes nothing and a running component keeps running.

An operation the bake cannot hold is refused

The rule is one sentence: a build accepts an operation exactly when a scene record carries its result. A record describes an entity's lifecycle, its place in the hierarchy, its name, its transform, its visibility, its active state, its attributes, its participation mode, its network scope, whether its live state replicates, and the script components it carries with their public data — so those are what a builder may do. An operation writing anything else would be live during the build and absent the next time the scene loaded, so it is refused instead of applied.

The set is an allowlist, which means an operation the bake has not learned to carry is refused rather than quietly permitted. A gap is loud instead of invisible.

A refusal names the operation, the entity, and the build it answered to:

this operation cannot be captured into a scene build, so it is refused rather
than applied and then lost: EntityDuplicate. ...
The capture scope refusing it is scope 9, opened at:
[C] function capture
...
/zero/source/tmp/Orchard.sceneModule/init.luau:5
a build may only mutate the entities it creates, and 'ent_ecb63dd39a782b25' is
not one of them, so entity.localPosition is refused rather than applied and
then lost. ...

So try it. If you do not know whether the build can hold something, write it and run the build — a refusal costs nothing. The refused call never lands, the build is abandoned whole rather than baked with a difference, the previous build stays in the scene exactly as it was, and nothing the builder did before the refusal is half-applied. You learn the answer in one rebuild, by name, and the scene is where you left it.

A runtime resource is the one refusal with an answer inside the build: renderer.mesh.create makes a GPU mesh that lives as long as the session, which no record can hold, and the same geometry made as an ASSET is held by every record that names it. That is build.asset, below. Everything else a refusal names goes outside the builder, or into a component the builder attaches, which runs at play.

Placing an asset that expands into a hierarchy

Instantiating a .bundle is a capturable operation, and so is every other asset that expands into a hierarchy — an avatar, a mesh, another .sceneModule. There are two ways to place one, and they bake differently.

Instantiate it directly and its whole hierarchy becomes the scene's own entities:

function content()
    local grove = entity.spawn("grove")
    asset.resolve("ProbeTree", "bundle"):instantiate(grove)
end

Every entity of the bundle lands as a record naming the build that placed it and its own place in the hierarchy, with its components baked in:

buildOwner=build  buildKey=grove                       Model
buildOwner=build  buildKey=grove/probe_bundle_child    Model

They are the scene's content from then on: selectable, movable, individually saved, and unaffected by a later edit to the bundle.

Attach an Asset component and the placement stays linked to the bundle:

function content()
    local slot = entity.spawn("tree_slot")
    slot.component.add("Asset", { source = "ProbeTree" })
end

The record holds slot and its Asset component. The bundle's own entities are spawned by that component wherever the record lands, as temporary children, so they are not records themselves — which is the general rule for anything a component the builder attached creates: the record names the component, and the component produces its subtree again on every load. Editing the bundle updates every placement.

Reach for the first when the result is this scene's content and you want to shape it here; reach for the second when the bundle is the source of truth and the placements should follow it.

An entity whose live state replicates

The world is always multiplayer, and a scene says per entity whether its live state is shared or local. entity.spawnSynced is that statement in a builder:

function content()
    -- Everyone sees the same door, and the peer that opens it opens it for
    -- everybody.
    local door = entity.spawnSynced("vault_door")
    door.component.add("Model", { model = asset.resolve("cube", "mesh") })

    -- A plain spawn is LOCAL: every peer gets one from `scene.json`, and a
    -- change one peer makes to it stays on that peer.
    local torch = entity.spawn("torch")
end

The record carries it and scene.json records it, so it survives the bake and the reload. At play the room creator spawns the synced entities and a joiner receives them off the relay snapshot instead of spawning its own — which is why one peer opening the door does not leave five doors standing.

A build is authoritative over this the way it is over everything else it places: change spawnSynced back to spawn and the rebuild makes the entity local again, keeping its id.

The assets a build makes

Some of what a build states is not an entity but an ASSET: a mesh a lathe produces, a texture a pattern writes, a material a palette picks. build.asset is how a build makes one.

function content()
    local tower = build.asset("mesh", "tower", function()
        local positions, indices = lathe({ { 2, 0 }, { 1.6, 4 }, { 1.4, 9 } }, 16)
        return { positions = positions, indices = indices }
    end)

    local e = entity.spawn("tower")
    e.component.add("Model", { model = tower })
end

The three arguments are the asset TYPE to make, the NAME to make it under, and the function that PRODUCES it — which takes nothing and returns the same creation parameters asset.create takes for that type. The call hands back the asset's AssetRef, which is what a component field names, so the scene saves a reference to a durable asset rather than to something that existed only while the build ran.

The producer runs when the build script changed, and never otherwise. The asset is authored inside the build's own folder — Lobby.scene/tower.mesh — and the build records which version of build.luau produced it:

Lobby.scene/
  build.luau       what the scene is made of
  tower.mesh       the asset that build makes
  .build.assets    which build.luau produced it
  scene.json       the entities, as the last build left them

So the scene reloads without re-authoring anything, and editing the lathe profile re-authors tower.mesh in place: same path, same guid. That is the part that matters — scene.json references assets by guid, so a Model naming tower on Monday still names the tower the code produces on Friday.

A .sceneModule makes assets the same way, into its own folder, keyed on its init.luau.

Producing is keyed on the whole script, so editing anything in build.luau re-authors every asset it makes. That costs a build; it never costs a reference.

Any asset type, not just meshes

The first argument is the type, and the producer returns whatever asset.create takes for THAT type — so the material a generated mesh is drawn with is itself something the build makes:

local stone = build.asset("material", "WallStone", function()
    return {
        shader = "pbr",
        properties = {
            base_color = { 0.62, 0.60, 0.55, 1 },
            roughness = 0.85,
            metallic = 0,
        },
    }
end)

e.component.add("Model", { model = wall, material = stone })

A material's overrides go in one properties table, colours as {r, g, b, a} arrays. mat.yaml groups them on disk under floats:, colors:, ints: and bools:, and reading a material file makes those look like creation keys — they are not, and passing one is refused with the shape to use instead.

The producer owns what it makes

A generated asset is the build's output, so the build is the only thing that writes it. Editing one at runtime — setting a property or a texture on the live material, hand-editing its files — puts it in a state the next produce does not know about, and what the code says no longer describes what is on disk. Change the producer and rebuild; that is the whole edit loop, and it is why the producer runs again exactly when its code changes.

sceneModule — a part of a scene, reused

A whole scene is one build.luau. A .sceneModule is the same idea for a part of one: a folder whose init.luau declares the inputs a placement may set and returns the builder.

inputs = {
    count = Field.number(3, NoSync),
    spacing = Field.number(1.5, NoSync),
}

return function()
    local root = entity.spawn("colonnade")
    for i = 1, inputs.count do
        local pillar = entity.spawn("pillar_" .. i, { parent = root })
        pillar.localPosition = { (i - 1) * inputs.spacing, 0, 0 }
        pillar.component.add("Model", { model = asset.resolve("cube", "mesh") })
    end
end

inputs follows the component public convention exactly — the same Field.* constructors, declared as a global the way a component declares public. A read of inputs.<name> inside the builder is the resolved value for the placement being built: the param that placement set, else the declared default. Reading a name the module never declared raises and lists the ones it does:

sceneModule: this module declares no input 'nosuch' (declared: count)

Place one with the SceneModule component:

local place = entity.spawn("colonnade_0")
place.component.add("SceneModule", {
    source = asset.resolve("Colonnade", "sceneModule"),
    params = { count = 4, spacing = 2 },
})

A scene's build.luau places one with the same two lines, which is how a row of slots each carrying its own variant is written as a loop. The placement is what the scene's build describes; the entities under it belong to the placement, which lands them again wherever the placement goes — so the scene holds one copy per placement however many times either is rebuilt.

What the builder made lands in the scene as ordinary entities — named, selectable, movable, saved in scene.json, replicated. The folder holds no baked artifact; the scene holds the output. Placing the same module twice with different params gives two different results from one asset, each with ids of its own.

Changing a placement's params, or editing init.luau, re-runs the builder for every placement and reconciles the result the same way a scene build does — same entities, same ids, updated contents. A placement that already holds a build runs nothing when the scene loads or the mode flips, and :rebuild() on the component forces one for a change nothing else reports:

place.component.get("SceneModule"):rebuild()

Who states a placement's params

A placement an author made holds its own params — spawn it, set them, and they are yours.

A placement a build.luau made is stated by that build.luau. The build names the placement's source and params and states them again on every run, and writing build.luau is what runs it. Setting a param on such a placement rebuilds it at once, and the value holds until the next run of the build, which states the params its own source gives — so it is a preview of what the build would say, and the file is where to change it for good.

Both ends of that say so. Setting the param names the file that states it:

[warn] a build placed this entity and states its SceneModule params — a param
set on the placement holds until that build runs again and states them from its
own source { placement="tent_ring_placement", set="count = 9",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }

and the run that states them again names what it replaced:

[warn] a build ran and stated this placement's SceneModule params over the ones
set on the placement — the file the build is written in is where they are stated
{ placement="tent_ring_placement", replaced="count: 9 → 7",
statedIn="/zero/source/scenes/main.scene/build.luau (content)" }

A run that states what the placement already holds says nothing, and a placement an author made is never stated by any build. The same holds for a placement one .sceneModule builder makes inside another: its init.luau is the file named.

Ask a module about itself without touching a scene:

local m = asset.resolve("Colonnade", "sceneModule")
m:inputs()          -- { { name = "count", kind = "number", default = 3 }, ... }
m:build({ count = 2 })  -- run the builder, return the records, scene untouched

Which one to reach for

  • One arrangement, belonging to one scene — its lighting, its layout, its player setup: the scene's own build.luau.
  • A thing several scenes place, or one scene places many times with different settings: a .sceneModule with the settings as inputs.

Both produce the same kind of result — real entities in the scene, baked and replicated — so a piece of a build.luau that starts appearing twice moves into a .sceneModule with no change to what the entities are.

  • guides { path: "core/scenes" } — loading, saving, layers, the player intent and the live players registry.
  • guides { path: "types/scene" } / guides { path: "types/sceneModule" } — the two asset types' own references.
  • guides { path: "core/components" } — the behaviour a builder attaches.
  • documentation
  • guide