---
title: "Scene (asset type)"
description: "A scene is a saved, loadable snapshot of an entity scenegraph inside a world: the entity hierarchy, transforms, components, lighting, and a declared player intent, plus an auto-discovered startup…"
section: "Types"
slug: "types-scene"
canonical: "https://origozero.ai/docs/types-scene"
updated: "2026-09-03T15:00:42.542361283+00:00"
tags: ["asset-type", "reference"]
---

# Scene (asset type)

Scenes load as **layers**. One root scene is active at a time; additional
scenes can load additively as overlays on top of it. Each scene owns its
own multiplayer relay room, so switching scenes moves connected players
together.

## When to use one

- You want a savable, re-loadable snapshot of an entity scenegraph plus
  its lighting and player setup.
- You want a runnable starting state for a level or screen.
- You want a scenes-as-data flow: `layers.load("lobby")`, edit live, then
  `layers.active:save()` to publish the edits back to the scene.

For a reusable entity assembly (a prop, a vehicle, a UI panel) that many
scenes spawn into themselves, use a `.bundle`. A scene is a whole-world
snapshot; a bundle is one prefab that lives inside scenes.

## Folder shape

A scene asset is a folder ending in `.scene`. Its identity is the folder
name with the suffix stripped (`Lobby.scene` → `Lobby`).

- `scene.json` — the scenegraph: `entities[]`, lighting, and the
  top-level `player` intent. **Required.** Written by the engine — a build,
  a save, a human dragging something in. Not hand-edited.
- `build.luau` — what the scene is made of, written as code. Runs while you
  author; saving bakes its result into `scene.json` (see Building a scene).
- `entrypoint.luau` — the startup script. Auto-discovered as a sibling of
  `scene.json`; define lifecycle callbacks here (see The entrypoint).
- `<name>.<type>/` — an asset the build makes, authored here by `build.asset`
  (see The assets a build makes), alongside `.build.assets`, which records
  which `build.luau` produced each of them.
- `prefabs/` — optional subfolder for scene-local bundles.
- `scripts/` — optional subfolder for scene-local Luau helpers, required
  from `entrypoint.luau`.
- `README.md` — optional per-scene notes about THIS scene. Author one only
  when there is scene-specific detail worth recording; the general scene
  model lives here, in `guides { path: "types/scene" }`.

`scene.json` is version 7. It carries `"format": "scene"`,
`"version": 7`, an `entities[]` array, and a top-level `"player"` string
intent.

## The `player` intent

Every scene declares how it handles players with a top-level `player`
field in `scene.json`:

- `"spawns"` — the scene carries an authored player setup, and the engine
  spawns a player for each connecting user from it. This is the default
  for a freshly created scene.
- `"none"` — the scene spawns no player and authors no camera. Author your
  own Camera entity for the view (menus, UI-only screens, cinematics).

### How `"spawns"` works

A `"spawns"` scene authors two things in `scene.json`:

1. A **PlayerPrototype** — an entity carrying the `PlayerPrototype`
   component, whose `body` field names a child entity (the body). The
   body carries an avatar (an `Asset` pointing at an avatar, e.g.
   `@builtin::avatars.humanoid`). A `CameraRig` child carries a `Camera`
   with a camera behavior (e.g. `orbital_follow`). The prototype root is
   authored `PrototypeOnly` so it exists as a clonable template.
2. A **PlayerSpawn** — an entity carrying the `PlayerSpawn` component,
   whose `prototype` field names the PlayerPrototype to instantiate and
   whose transform is where players appear.

The scaffolded scene groups these under two organizing entities,
`PlayerSetups` (holding the prototype) and `Spawns` (holding the spawn),
so the authoring tree stays legible.

On each user join — entering play, and each subsequent connect — the
engine runs the spawn for that user:

- It clones the PlayerPrototype subtree.
- The clone root becomes the user's **internal identity** — a registry-only
  entity named `user` carrying the `UserIdentity` component. The identity
  is the anchor for who the player is; it has no world presence.
- The body named by `PlayerPrototype.body` is adopted as the user's
  **avatar** — their visible presence in the world — and placed at the
  PlayerSpawn's world transform. Move the PlayerSpawn (or drag it in the
  editor) to set where players appear.
- The `CameraRig`'s Camera follows the bound avatar.

The player prototype is live in edit mode so you can see and adjust it. In
play mode it is deactivated and marked internal — it stays a clonable source while
each user gets their own clone.

### Reaching the live player

The player surface lives on the root scene's registry,
`layers.active.players`:

- `players.localPlayer` — this client's player, a curated handle.
- `players.localReady` — `true` once the local player's avatar is bound
  (or the scene opted its avatar out).
- `players:list()` / `players:count()` — the players connected in this
  scene's room.
- `players:get(userId)` — a connected player by account id.
- `players:ownerOf(avatar)` — the player whose body is a given avatar.

A player handle is a data record about who the player is, plus a link to
their body:

- `player.avatar` — the body's entity ref. Act on the body through it
  (`player.avatar.position`, raycasts, rig access).
- `player.isLocal` — whether this client owns the player.
- `player.ready` — whether a live avatar is bound.
- `player.userId` / `player.identity` / `player.displayName` — the
  player's account identity.
- `player.avatar = ref` — bind a live body entity as the player's body
  (assign nil to clear). To use a `.bundle` / `.avatar` asset, spawn it
  into the world first, then bind the resulting entity.

The player's identity entity is the internal anchor; the handle exposes the
body (`player.avatar`) and the player's data, and reaching for
`.entity` / `.id` / `.component` raises with a redirect to `player.avatar`.

## Building a scene

`build.luau` declares what the scene holds. It is ordinary engine code —
`entity.spawn`, `component.add`, transform writes, `for` loops, a `require`
of a module that computes a layout — and what it creates is what ends up in
the scene.

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

The build runs while you author, in **edit** mode, and what it created lands in
the live scene. Saving the scene bakes it into `scene.json`, which is what makes
the entities durable content: a human can select and inspect them, they
replicate, and they are there when the world opens without `build.luau` running
at all. Play mode never runs it — in play the entities load from `scene.json`
like anything else the scene holds.

**Editing the file rebuilds the scene in place.** Writing `build.luau` is the
signal; there is nothing to reload, and the rebuild saves. An entity keeps the
id it had, so a reference to it — a spawn naming a prototype, another entity's
component naming this one — survives every rebuild. Add a crate to the loop and
the other four do not move.

The write returns as soon as the file is on disk and the rebuild finishes behind
it, so what it landed comes back as a notice:

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

An edit saved while a rebuild is still running is an edit to code that rebuild
has already read, so 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="7", 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.

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. That one leaves the result unsaved: it is either what `scene.json`
already held, or an edit for the author to keep or discard like any other.

### The two surfaces

- `content()` — the scene's own entities. Present in play.
- `editorOnly()` — entities present while authoring and absent in play. They
  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. Anything an author needs to see and a player does not goes here
  — an alignment guide, a spacing marker, a debug volume.

Both are optional. A scene with neither has no build.

### 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 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.

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

### 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. After a write it is unnecessary; the write has
already run it.

```lua
layers.active.asset:build()          -- rebuild and save
layers.active.asset:build({ save = false })
```

It answers with how many entities each surface placed —
`{ content = 6, editorOnly = 1 }` — and with nil for a scene that has no
`build.luau`. Called while a build for that scene is already running, it returns
at once with `{ inFlight = true, scene, trigger, startedBy, message }` naming
that build instead of starting a second one; `inFlight` is what tells the two
answers apart.

An operation the bake cannot hold is **refused** rather than applied. A build
accepts what its records carry: entity lifecycle, transforms, hierarchy, names,
visibility, active state, participation mode, network scope, attributes, and
components with their public data. Anything else raises while the build runs,
naming the operation and the build it answered to, instead of leaving the scene
depending on something no reload brings back. When that happens the previous
build stays exactly as it was. A runtime resource a build needs — a mesh it
generates, a texture it writes — is made as an asset instead, below. Everything
else a refusal names goes outside the builder, or into a component the builder
attaches, which runs at play.

### The assets a build makes

`build.asset(type, name, produce)` makes an asset the build's content names — a
mesh a lathe produces, a texture a pattern writes. `produce` takes nothing and
returns the creation parameters `asset.create` takes for that type; the call
returns the asset's `AssetRef`, which is what a component field holds.

```lua
function content()
    local tower = build.asset("mesh", "tower", function()
        local positions, indices = lathe(profile, 16)
        return { positions = positions, indices = indices }
    end)

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

The asset is authored inside the scene's own folder — `Lobby.scene/tower.mesh`
— and the scene records which `build.luau` produced it in `.build.assets`. So
`produce` runs when the build script changed and never otherwise: a reload
re-runs the build and reuses the asset, while editing the profile re-authors
`tower.mesh` **in place**, keeping its path and its guid. That is what holds the
reference — `scene.json` names assets by guid.

For a part of a scene that several scenes place — or that one scene places many
times with different settings — see `guides { path: "types/sceneModule" }`,
which is the same idea with declared `inputs` a placement sets.
`guides { path: "core/scenes-as-code" }` covers both.

## The entrypoint

`entrypoint.luau` is the scene's **runtime** — what happens while the game is
being played. What the scene *is* comes from `build.luau` above; nothing here
has to construct it.

`entrypoint.luau` is auto-discovered as a sibling of `scene.json`. The
loader runs it in a fresh environment and folds its top-level
`function name(...)` declarations into the matching scene lifecycle event.
Define the callbacks the scene needs — the recognised set:

- `onLoad()` — the scene loaded in **play** mode. Gameplay-start hook,
  runs on every connected client.
- `onHostLoad()` — the scene loaded in **play** mode, on the client that
  owns the scene's synced content (the relay room creator; offline and
  single-player count as host). Fires once. Entities spawned here — and
  anywhere downstream — become synced automatically, so this is where
  shared world content is spawned: it runs on exactly one peer, and every
  other peer receives the entities from the relay snapshot.
- `onEditLoad()` — the scene loaded in **edit** mode. Custom per-scene
  authoring tools.
- `onUnload()` — before the scene's entities are torn down. Extra cleanup;
  engine-managed teardown (entity despawn, resource release) runs
  separately.
- `update(dt)` — per frame in play mode, while the gameplay clock
  runs.
- `editorUpdate(dt)` — per frame in edit mode, running or paused.
  Play-paused ticks neither hook. The scene loader partitions these
  strictly on mode; a component's hooks of the same name read
  `engine.paused` instead, so the two rules differ — `man components`
  has that table.
- `localPlayerReady(player)` — once the local player's avatar is ready.
  Gameplay wiring for the local player: attach state, bind UI, swap the
  avatar. The avatar is already placed at the PlayerSpawn, so this is for
  behavior, not positioning.
- `playerJoined(player)` — a remote player joined this scene's room. Use
  for per-player UI (nameplates, greetings). The local player arrives
  through `localPlayerReady`.
- `playerLeft(eid)` — a remote player left this scene's room. Receives the
  departed entity's id (the entity is already despawned).

Each scene's entrypoint runs in its own environment, so two scenes can
declare the same top-level names without clashing. Keep each callback
short; push per-scene logic into `scripts/` modules and `require` them.

## Layers

Scenes load through the `layers` namespace.

- `layers.load(ref, opts?)` — load a scene. `ref` is a scene AssetRef or
  identity string. Without `opts.additive`, this is a **root** load: it
  replaces the current root scene. `opts`:
  - `additive` — load as an overlay alongside the current root instead of
    replacing it.
  - `persistent` — keep an additive overlay mounted across mode flips and
    root swaps.
  - `origin` — a `{x, y, z}` world offset for the overlay's entities.
  - `deferred` — spread the entity spawn across frames (for
    thousand-entity scenes); the load signals complete only once the
    scene has settled.
- `layers.active` — the current root scene proxy (nil before any load).
- `layers.list()` — every loaded layer (root + additive overlays).
- `layers.find(ref)` / `layers.is_loaded(ref)` — look up a loaded layer by
  ref.
- `layers.onLoad(cb)` / `layers.onUnload(cb)` / `layers.onBeforeLoad(cb)` —
  subscribe to every scene load / unload (distinct from a scene proxy's
  own `:on_load` / `:on_unload`, which fire only for that scene).

A root scene carries the `players`, `camera`, and `settings` surfaces;
additive overlays inherit the root's players and lighting and do not carry
their own.

### The scene proxy

`layers.active` (and `layers.find` / `layers.list` entries) returns a
scene proxy:

- Fields: `.guid` (canonical identity), `.name`, `.path`, `.asset`,
  `.additive`, `.visible`, `.state` (`"loading"` → `"loaded"` → `"ready"`
  → `"unloading"`), `.ready`, `.players`, `.camera`, `.settings`,
  `.entrypoint`.
- `.camera` — a proxy over the scene's primary camera entity; read and
  write the live Camera component through it, and
  `.camera.behaviors._list()` enumerates the available camera behaviors.
- `:save(opts?)` — publish the live scenegraph to canonical `scene.json`
  (see Saving). A convenience that forwards to the scene ASSET's `save`
  (`self.asset:save(opts)`): the proxy is the runtime view and owns nothing
  on disk, so persistence lives on the asset.
- `:reload()` — unload and reload the same scene (a runtime respawn).
- `:clearDirty()` — discard unsaved edits and respawn from canonical.
  Forwards to the scene asset's `discard` (`self.asset:discard(opts)`).
- `:sceneAsset()` — the durable `AssetRef<scene>` this live layer maps to.
- `:promoteDirty()` / `:writeDirty()` / `:hasDirty()` — manage the dirty
  overlay directly.
- `:unload()` — despawn the layer and free its slot.
- `:onReady(cb)` — fire once the scene reaches `ready` (latched: a late
  subscriber fires immediately).

## Multiplayer & relay rooms

Every scene has its own relay room, keyed by the world guid, the boot
profile, the mode, and the scene guid. Switching scenes with
`layers.load` runs a room transition — leave the old room, tear down,
join the destination scene's room — so connected users move together and
teardown of the old scene never leaks to peers in the new one.

In play mode connected to a relay, the scene's entities are
host-authoritative: only the room creator instantiates them (marked
synced), and every joiner receives them from the relay snapshot rather
than spawning their own. This is why shared world content belongs in
`onHostLoad` — spawning it in `onLoad` would run on every peer and
duplicate it. Each player's own avatar spawns per-user from the
PlayerSpawn on every client.

Edit mode and offline boots spawn locally and never auto-sync.

## Loading and saving

**Loading** — `layers.load(ref)` for a root scene:

1. Despawns the current root scene's non-persistent entities.
2. Instantiates the entities from `scene.json` (including the player
   prototype and spawn for a `"spawns"` scene).
3. Applies lighting and sky.
4. Runs `entrypoint.luau` and fires lifecycle callbacks as their events
   occur.

**Saving** — persistence lives on the scene ASSET. `sceneRef:save()`
writes the scene's current state back to canonical `scene.json`; when the
scene is loaded it captures the live scenegraph, and when it is not loaded
it promotes the pending dirty overlay. `layers.active:save()` is the
convenience form — it forwards to the active layer's mapped asset
(`layers.active.asset:save()`). Writing canonical is the only path that
does so: edit ↔ play cycles never save, so hitting play to test never
overwrites the scene. Between saves, live edits accumulate in a per-scene
dirty overlay that the loader merges on top of canonical.

`sceneRef:discard()` throws the overlay away and restores the saved state
— deleting the overlay, and respawning the live layer when the scene is
loaded. It works whether or not the scene is loaded, because the overlay
belongs to the asset, not the runtime layer. `layers.active:clearDirty()`
forwards here. Saves capture entity data, not the entrypoint script —
edits to `entrypoint.luau` and `scripts/` are always preserved.

`opts.to` on `save`/`discard` targets a different scene ("save as"),
taking either a bare name (`"level_2"`) or a full VFS path.

## Lighting

Lights are ordinary entities: a directional light, an ambient light, and
a sky are entities carrying `Light` / `ProceduralSky` components in
`scene.json`. Author, move, and tune them like any other entity. If a
scene is missing any of {directional light, ambient light, sky}, the
loader adds a temporary default for the absent one so a scene never loads
dark or skyless; the defaults are never saved into the scene.

Scene-level lighting state that is not an entity — clear color and sky
descriptor — lives under `layers.active.settings.lighting` (`clear_color`,
`sky`); writing those fields updates the engine live.

## Modes

Scenes load in both edit and play mode (see `man modes`). The mode
governs behavior:

- An entrypoint's `update(dt)` runs in play while the gameplay clock
  runs, and its `editorUpdate(dt)` runs in edit. A component's hooks of
  the same name follow the clock rather than the mode — `man
  components` has the four states that produces.
- Player prototypes are live in edit and deactivated + internal in play,
  where each user instead gets a clone. `EditorOnly` entities follow the
  same edit-live / play-internal toggle.
- `onLoad` / `onHostLoad` fire on a play-mode load; `onEditLoad` fires on
  an edit-mode load.

## How to create one

```luau
asset.create("scene", "<Name>")
-- Creates /zero/source/<Name>.scene/ with:
--   build.luau      where this scene's content is written. A new one starts
--                   with the sun / ambient / sky lighting and the "spawns"
--                   player setup, commented for editing — everything else the
--                   scene comes to hold is written here too.
--   scene.json      what that code produces, baked.
--   entrypoint.luau commented lifecycle-callback stubs.
```

A freshly created scene has a player with a following camera on the first press
of play. It carries no ground, so the first thing to author is what the player
stands on — a `content()` line away in `build.luau`.

## Discovery

- `asset.list("scene")` — every registered scene.
- `asset.inspect("<name>")` — entity count, lighting summary, source path,
  and this type README.

## Authoring conventions

- Keep static layout in `scene.json` — geometry, lights, props, the player
  prototype and spawn.
- Keep dynamic setup in `entrypoint.luau` lifecycle callbacks — event
  listeners, gameplay state, services attached to spawned entities.
- Spawn shared, synced world content from `onHostLoad`; spawn each
  player's own gameplay wiring from `localPlayerReady`.
- Set the world's startup scene in `/.world_settings` — that is the scene
  the world boots into.
- Y is up; +Z is forward.
- Renaming the folder changes the scene's identity — update every
  `layers.load` callsite and the world's startup-scene field.

## Related types

- `.bundle` — reusable entity hierarchies that scenes spawn into
  themselves.
- `.service` — cross-scene background work that outlives a scene load.
- `.world_settings` — picks the startup scene and world-level renderer /
  physics options.
