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 require — entity.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.