---
title: "sceneModule — a part of a scene, authored as code"
description: "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."
section: "Types"
slug: "types-scenemodule"
canonical: "https://origozero.ai/docs/types-scenemodule"
updated: "2026-10-20T12:04:40.424671663+00:00"
tags: ["asset-type", "reference"]
---

# sceneModule — a part of a scene, authored as code

```
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

```lua
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.

```lua
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.

```lua
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.

```lua
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 of an editor session the placement builds into the play scene alone and
records nothing. A runtime-profile boot builds nothing: the entities are already
in `scene.json` and load as authored content, and 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.

## In the Inspector

Selecting a scene module opens its **Inputs** section: a table of the inputs the builder declares, each with its kind, default and allowed values, and an opener for `init.luau` in the Code panel. A placement sets their values on its SceneModule component.
