Log inGet started

sceneModule — a part of a scene, authored as code

A .sceneModule is a recipe for scene content: a folder whose init.luau declares the inputs a placement may set and returns the function that builds the content. Placing one runs the builder and lands…

CoinPusher.sceneModule/
  init.luau       -- declared inputs + the build code
  Coin.mesh/      -- an asset the build makes (see The assets a build makes)
  .build.assets   -- which init.luau produced each of them
  README.md
  .metadata

The entities are not baked into the folder — the scene holds those. What the folder holds is the code, and the assets that code makes.

The builder is ordinary engine code

inputs = {
    seed      = Field.number(0, NoSync),
    coinCount = Field.number(40, NoSync),
    stroke    = Field.number(0.8, NoSync),
}

return function()
    local base = entity.spawn("base")
    base.component.add("Model", { model = asset.resolve("PusherBase", "mesh") })

    local pusher = entity.spawn("pusher", { parent = base })
    pusher.component.add("PusherDrive", { stroke = inputs.stroke })

    math.randomseed(inputs.seed)
    local coinMesh = asset.resolve("Coin", "mesh")
    for i = 1, inputs.coinCount do
        local coin = entity.spawn("coin_" .. i, { parent = base })
        coin.localPosition = { math.random() * 1.6 - 0.8, 0.5, math.random() * 2 - 1 }
        coin.component.add("Model", { model = coinMesh })
    end
end

Real loops, real conditionals, real locals, real requireentity.spawn, component.add, and transform writes are the same calls they are anywhere else. The builder authors; components behave. A coin pusher's machine, coins, and colliders are construction, so they belong in the builder; PusherDrive is runtime logic, so it stays a component the builder attaches.

The builder runs inside a capture scope, which records what it created and holds it to the operations that end up in the scene. A build accepts an operation exactly when a scene record carries its result; anything else is refused by name while the build runs — never applied and then dropped, so nothing the builder appears to do goes missing later.

A refusal is cheap, so try the thing you are unsure of. The refused call never lands, the build is abandoned whole, and the previous build stays in the scene untouched — one rebuild tells you the answer and costs nothing. guides { path: "core/scenes-as-code" } states the rule and works through placing an asset that expands into a hierarchy, such as a .bundle.

Anything the entry script pulls in uses an absolute require (require("@builtin::modules.json")): the script is compiled into its own environment, which is what gives inputs its meaning below.

That environment holds inputs and build, and falls through to the engine's global one for every other name, so a builder reaches everything a script reaches anywhere else — entity, asset, Transform, math, require, vfs, task, tools, world, string, table, os, Field, NoSync. Transform.quatFromAxisAngle(ax, ay, az, angle) and require("@builtin::modules.json") behave inside the builder exactly as they do outside it, so the engine's own maths and modules are the ones to reach for. A toolbox is reached with tools.use("wld", "mode"), the same call it takes anywhere.

Inputs

inputs follows the component public convention exactly. The table literal declares the fields with the same Field.* constructors components use, and a read of inputs.<name> inside the builder returns the resolved value for the placement being built — the param override when the placement sets one, else the declared default.

inputs = {
    -- a placement that sets nothing builds with 0
    seed  = Field.number(0, NoSync),
    style = Field.enum({ "brass", "steel" }, "brass", NoSync),
    lit   = Field.bool(true, NoSync),
}

The replication mode is the one every component field names, on the same constructors. The placement carries the whole resolved set in one params field, which is what replicates, so an input declares NoSync.

Declare inputs as a global, the way a component declares public. Reading a name the module never declared raises and lists the ones it does, so a typo says so instead of silently building with nothing.

The assets a build makes

Some of what a module states is an asset rather than an entity — the coin mesh above is one. build.asset(type, name, produce) makes it: produce takes nothing and returns the creation parameters asset.create takes for that type, and the call returns the asset's AssetRef, which is what a component field holds.

local coinMesh = build.asset("mesh", "Coin", function()
    local positions, indices = disc(0.4, 24)
    return { positions = positions, indices = indices }
end)

local coinGold = build.asset("material", "CoinGold", function()
    return {
        shader = "pbr",
        properties = {
            base_color = { 1, 0.84, 0.28, 1 },
            metallic = 1,
            roughness = 0.25,
        },
    }
end)

return function()
    for i = 1, inputs.coinCount do
        entity.spawn("coin_" .. i).component.add("Model", {
            model = coinMesh,
            material = coinGold,
        })
    end
end

The first argument is the type, so a module makes every kind of asset its content names, not only geometry. 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: / bools:, which reads like a set of creation keys and is not one; passing a section name is refused with the shape to use.

The producer owns what it makes. Editing a generated asset at runtime — setting a property on the live material, hand-editing its files — leaves it in a state the next produce does not know about. Edit the producer and rebuild.

The asset is authored inside the module's own folder — CoinPusher.sceneModule/ Coin.mesh — and the module records which init.luau produced it in .build.assets. So produce runs when the entry script changed and never otherwise: every later placement and rebuild reuses the asset, while editing the disc re-authors Coin.mesh in place, keeping its path and its guid. That is what holds the reference — scene.json names assets by guid.

This is the answer to a refusal that names a runtime resource: 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.

Placement

A sceneModule defines instantiate, so it is accepted anywhere a scene-instantiable asset is: an Asset-style field gated by Field.instantiableRef, a viewport drop, a tool argument.

local pusher = entity.spawn("pusher_0")
pusher.component.add("SceneModule", {
    source = asset.resolve("CoinPusher", "sceneModule"),
    params = { seed = 0 },
})

SceneModule is the placement component: it holds the module the placement is bound to and the params it sets, and it is what re-runs the build. Placing the same module twice with different params yields two different results from one asset, each with its own stable ids. See its README for the full field set.

Rebuilds hold ids

Editing init.luau, or changing a placement's params, re-runs the builder in edit mode and reconciles the result: an entity the previous build placed is updated in place, one the build no longer emits is removed, and a new one is spawned. Same entities, same ids, updated contents — so a reference to a built entity survives every rebuild.

A change is what rebuilds. A placement that already holds a build runs nothing when the scene loads or the mode flips — the entities are there, holding what the last bake gave them. SceneModule:rebuild() forces a rebuild for a change nothing else reports, such as a module edited while the world was closed.

In play nothing runs and nothing instantiates. The entities are already in scene.json and load as authored content; the placement component is inert.

Ref methods

  • ref:build(params) — run the builder and return the records, leaving the scene untouched.
  • ref:inputs() — the declared inputs, sorted: { name, kind, default, values? }.
  • ref:getInitScript() — the entry script source.
  • ref:instantiate(target?, opts?) — the uniform placement call.
  • asset-type
  • reference