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, thenlayers.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-levelplayerintent. 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 intoscene.json(see Building a scene).entrypoint.luau— the startup script. Auto-discovered as a sibling ofscene.json; define lifecycle callbacks here (see The entrypoint).<name>.<type>/— an asset the build makes, authored here bybuild.asset(see The assets a build makes), alongside.build.assets, which records whichbuild.luauproduced each of them.prefabs/— optional subfolder for scene-local bundles.scripts/— optional subfolder for scene-local Luau helpers, required fromentrypoint.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, inguides { 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:
- A PlayerPrototype — an entity carrying the
PlayerPrototypecomponent, whosebodyfield names a child entity (the body). The body carries an avatar (anAssetpointing at an avatar, e.g.@builtin::avatars.humanoid). ACameraRigchild carries aCamerawith a camera behavior (e.g.orbital_follow). The prototype root is authoredPrototypeOnlyso it exists as a clonable template. - A PlayerSpawn — an entity carrying the
PlayerSpawncomponent, whoseprototypefield 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
usercarrying theUserIdentitycomponent. The identity is the anchor for who the player is; it has no world presence. - The body named by
PlayerPrototype.bodyis 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—trueonce 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/.avatarasset, 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.
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 theEditorOnlyparticipation 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.
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.
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 readengine.pausedinstead, so the two rules differ —man componentshas 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 throughlocalPlayerReady.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.refis a scene AssetRef or identity string. Withoutopts.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 canonicalscene.json(see Saving). A convenience that forwards to the scene ASSET'ssave(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'sdiscard(self.asset:discard(opts)).:sceneAsset()— the durableAssetRef<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 reachesready(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:
- Despawns the current root scene's non-persistent entities.
- Instantiates the entities from
scene.json(including the player prototype and spawn for a"spawns"scene). - Applies lighting and sky.
- Runs
entrypoint.luauand 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 itseditorUpdate(dt)runs in edit. A component's hooks of the same name follow the clock rather than the mode —man componentshas the four states that produces. - Player prototypes are live in edit and deactivated + internal in play,
where each user instead gets a clone.
EditorOnlyentities follow the same edit-live / play-internal toggle. onLoad/onHostLoadfire on a play-mode load;onEditLoadfires on an edit-mode load.
How to create one
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.luaulifecycle callbacks — event listeners, gameplay state, services attached to spawned entities. - Spawn shared, synced world content from
onHostLoad; spawn each player's own gameplay wiring fromlocalPlayerReady. - 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.loadcallsite 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.