Log inGet started

Scene (asset type)

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…

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.sceneLobby).

  • scene.json — the scenegraph: entities[], lighting, and the top-level player intent. Required.
  • entrypoint.luau — the startup script. Auto-discovered as a sibling of scene.json; define lifecycle callbacks here (see The entrypoint).
  • 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 man 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. third_person). 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 hidden 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 hidden — 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.localReadytrue 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 hidden 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.

The entrypoint

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.
  • editorUpdate(dt) — per frame in edit mode.
  • 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 layer's live state to canonical scene.json (see Saving).
  • :reload() — unload and reload the same scene.
  • :clearDirty() — discard unsaved edits and respawn from canonical.
  • :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

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

Savinglayers.active:save() writes the live scenegraph back to canonical scene.json. This is the only path that writes canonical: 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; :save() promotes the overlay into canonical and clears it. :clearDirty() throws the overlay away and respawns the saved state. Saves capture entity data, not the entrypoint script — edits to entrypoint.luau and scripts/ are always preserved.

opts.to on :save() writes to 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:

  • Component and entrypoint update(dt) run in play; editorUpdate(dt) runs in edit.
  • Player prototypes are live in edit and deactivated + hidden in play, where each user instead gets a clone. EditorOnly entities follow the same edit-live / play-hidden toggle.
  • onLoad / onHostLoad fire on a play-mode load; onEditLoad fires on an edit-mode load.

How to create one

asset.create("scene", "<Name>")
-- Creates /zero/source/scenes/<Name>.scene/ with:
--   scene.json      lighting (sun / ambient / sky) + a "spawns" player
--                   setup: a PlayerPrototype with a humanoid body and a
--                   third-person CameraRig, plus a PlayerSpawn.
--   entrypoint.luau commented lifecycle-callback stubs.

A freshly created scene boots into a walkable player with a following camera immediately.

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.
  • .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.
  • asset-type
  • reference