A game that is more than one scene needs a page about scenes

Scenes.md explains what a scene swap keeps (the shared state, so global
variables and global machines) and what it throws away, why the run
starts either from the start scene or from the open one, and why the
sounds of the old scene stop: their behaviors are gone, which is also
why Play sound learned detached.
admin committed 2026-09-15 13:24:35 +02:00
1 parent b3868fcb65
commit e6849977eb
8 files changed
+146 -8

No files matched your search

+25
@@ -177,6 +177,31 @@ browser the way it is free of Pixi, lets a test run a world that plays into a re
a device, and is the reason a run started before anything wired a sink in plays silently rather
than crashing.
**A voice has an owner, so nobody has to remember to stop it.** `AudioSink.play` takes the id of
the behavior that asked, and destroying that behavior silences its voices — the reaping pass in
`BehaviorSystem` does it right after `On destroy`, so a graph still gets its last word. That single
rule is also what ends the sounds of a scene a `Load scene` swaps away, without the swap knowing
anything about audio. `detached` gives the ownership up on purpose, for the death sound of an object
that destroys itself; an empty owner is deliberately *nobody*, never a group `stopOwner('')` could
silence by accident. Cancellation is counted rather than flagged (per file, per owner, and for
"everything"), because a play whose bytes are still in flight has to know whether a stop happened
*after it was asked for* — a flag cannot tell that from one that happened long before.
**A scene swap is a new world inside the same run.** `Load scene` sets a flag on the world
(`loadSceneRequested`) the way `Stop game` and `Restart game` do, and the editor acts on it once
the tick is over: half a frame in each of two worlds is undebuggable. What survives is one object —
the `SharedState` handed to the next `BehaviorSystem` through `carryOver` — which is exactly the
line between "belongs to the run" and "belongs to the scene". Carrying it over also suppresses the
entry event of the global machines, because standing where you already stand is not a move.
**Where a run begins is a project field, which of the two questions Play asks is a browser
setting.** `project.startSceneId` is content — it travels with the project, and a dangling id reads
as none rather than being cleaned up, so undoing a deletion brings it back. The switch next to Play
lives in `editor/settings.ts` (`localStorage`, best effort: a browser that refuses it gets the
defaults and keeps working), because "I am working on level 4 this afternoon" is not something the
project should carry into anyone else's checkout. The new field needed no format bump: a document
without it reads as "none named", which is precisely what older projects meant.
**A predicate named after what a thing looks like will be asked what a thing *is*.** `isSymbolType`
reads as "draws an icon", but four callers use it for "is editor decoration": the running world, the
Game tab, the draw order and the upright placement all key off it. Giving a particle source an icon
+4 -2
@@ -99,7 +99,7 @@ shown on the node and editable in the inspector.
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On enable`, `On update`, `On disable`, `On destroy` (see *Lifecycle*), `On key down/up/held`,
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner — anywhere when it has none), `On animation finished`, `On state entered`, `On state exited` (one output per state, see *State machines*), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End`, and the three that move or follow a state machine, `State branch`, `Set state`, `State transition` (see *State machines*) · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
`Is asset` (is this object an instance of asset X — how you tell what you collided with),
`Variables`, `Properties` (every property row of the target's type, the transform rows `scale`, `rotation`, `mirrorX`, `mirrorY` included — how a graph turns a sprite), `Get position` (local and world), `Is enabled`, literals `Number`/`Text`/`Boolean`/`Texture`/`State machine`/`Sound`, `Add/Subtract/Multiply/Divide`,
`Variables`, `Properties` (every property row of the target's type, the transform rows `scale`, `rotation`, `mirrorX`, `mirrorY` included — how a graph turns a sprite), `Get position` (local and world), `Is enabled`, literals `Number`/`Text`/`Boolean`/`Texture`/`State machine`/`Sound`/`Scene`, `Add/Subtract/Multiply/Divide`,
`Min/Max/Abs/Sqrt/Clamp`, `Floor/Ceil/Round`, `Modulo`, `Random`, `Time`, `To text`, `To number`, `Join text`,
`Substring`, `Find text`, `Equals`,
lists `List` (a constant), `Fill` (one value repeated — how an empty board is made), `Count`,
@@ -112,7 +112,9 @@ shown on the node and editable in the inspector.
(asset setting + x/y/z, optional parent → outputs the new object), `Destroy`, `Emit` (starts a particle source over, see *Particles*),
`Play sound` / `Stop sound` (see [Sounds](Sounds): the asset says how loud and whether it loops, the node's
`volume` multiplies that and its `loop` either leaves it alone or overrules it in either
direction; every play is a voice of its own, so two in a row overlap), `Log`, `Stop game`, `Restart game`. A node type is one file exporting a `NodeDef`
direction; every play is a voice of its own, so two in a row overlap, and the voice ends with the
behavior that started it unless `detached` says otherwise),
`Load scene` (swaps the running scene once the frame is over — see [Scenes](Scenes)), `Log`, `Stop game`, `Restart game`. A node type is one file exporting a `NodeDef`
(ports, settings, `run`), listed in `nodes/index.ts`; palette, ports, validation and
inspector fields are generated from it. A port may declare a `default` for its inline value
(e.g. `Move.scale = 1`), shown on the node and in the inspector.
+8 -4
@@ -20,8 +20,10 @@
| **Default** | Every property has a schema default. Asset values that differ from it are marked gold in the inspector and can be reset (↺) the same way an override can. The asset's name in the **Assets** panel carries the same gold while any of them does (`assetDeviates`, `src/core/properties/deviation.ts` — the inspector marks the rows of that same schema, so the list cannot claim a deviation the inspector then fails to show). A behavior's variables are not in it: their default is declared on the variable itself, so the asset value cannot differ from it. |
| **Instance** | An object resolved from a placed container's template. Its **values** can be edited — position, name, property overrides — and only what actually deviates is stored, as a sparse `instanceEdits` entry on the container object keyed by the template path. Everything else keeps following the template. Its **structure** cannot: it can not be removed, re-parented or given a collider, and nothing of the scene can be parented into it. ↺ ▾ resets to the nearest base, which inside a container is the **template as the containers in between leave it**; ↑ ▾ offers **Apply to container** (write the value into the template, where every other instance still at the old value picks it up) and/or **Apply to asset** (which drops the template's override as well). With one base left the menu collapses to a plain ↺ / ↑, with none the buttons disappear. Gold marks the same thing the buttons offer. |
| **Unpack** | Writes a placed container's content into the scene as ordinary objects (hierarchy context menu, or the inspector's *Content* section). The ids survive it, so the selection does; from then on the structure is editable and the objects no longer follow the template. |
| **Start scene** | The scene a run *begins* with, named on the project rather than on any asset (right-click a scene in the Assets panel → *Set as start scene*, `▶` marks it in the list; *Clear start scene* takes it back). It is a different question from which scene is **active**, which is the one you have open in the editor — the switch next to Play says which of the two Play asks. Without a start scene named, both are the same thing. See [Scenes](Scenes). |
| **Load scene** | The node that swaps the running scene for another one, inside the same run. Everything of the old scene is destroyed — its objects, their behaviors, and the sounds those behaviors were playing — while what belongs to the **run** carries on: the values of the Variables assets, the states of the global machines, and the clock. See [Scenes](Scenes). |
| **Edit mode** | The Scene tab draws the project model; panels, grid, gizmos and drag-to-move are active. |
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from its active scene (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** — the saved model is never touched. |
| **Play mode** | The editor switches to a **runtime copy** of the project (same ids). A `World` is instantiated from the scene the switch next to Play names — the start scene, else the active one (one entity per scene object, resolved properties as mutable state) and shown in the **Game** tab. Systems tick it and mirror their changes into the copy; every panel stays open and edits the copy, which the world picks up immediately. **Stop discards world and copy** — the saved model is never touched. |
**The camera defines what is rendered.** A camera asset has a view size (`width` x `height`,
default 640x360) and a `zoom`; the frame it covers in world units is `size / zoom`. The Scene
@@ -35,7 +37,9 @@ no movement): everything that happens is defined by behavior graphs.
**Toolbar and panels.** The toolbar holds the **Project** menu (new project, switch project,
*Delete this project…*), the project name (type to rename), the **Scene** picker for the active
scene, Undo/Redo, Play/Pause, and on the right the notice line, the save status and the frame
scene — usable while a run is on, because it only moves the editor's view and never the running
world — Undo/Redo, the **start-scene switch** (*Start scene* / *Active scene*, see **Start
scene**), Play/Pause, and on the right the notice line, the save status and the frame
rate. The **Assets** panel's `+` creates an asset and opens its tab; its menu lists the types in
two alphabetical groups, first what can be placed into a scene, then what only lives in the library
(#43) — the line between them answers the question that follows right after "which type?".
@@ -44,8 +48,8 @@ selects (the inspector shows it, no tab moves); a **double-click** opens the tab
in — Scene, Behavior, State machine, Shader, Particles, Texture, Animation or Container — and on a scene
also makes it the active one. `Enter` does the same once the list has keyboard focus (`Tab`).
A sprite, camera, text or collider has no tab of its own, so a double-click on one does nothing.
Right-clicking a row offers *Set as active scene*, *Create sprite from texture*, *Create animation
from texture* and *Delete…*. In
Right-clicking a row offers *Set as active scene*, *Set as start scene* / *Clear start scene*,
*Create sprite from texture*, *Create animation from texture* and *Delete…*. In
**Scene objects** it offers *Select asset*, *Add collider*, *Create container*, *Unpack
container*, *Unparent*, *Expand all* / *Collapse all* and *Remove*.
+7 -1
@@ -64,7 +64,7 @@ The e2e run is a list of steps in `tools/e2e/` (hierarchy, inspector, play, asse
graph, parenting, colliders, containers, text/enabled, menus, textures, frames, overflow, aspect,
container tab, run-in-editor, pause, nested behaviors, key repeat, list tab, struct variable,
variable nodes, property nodes, animations, state machines, console, shaders, transforms, fonts,
particles, shared scope, render on change, sounds) that build on each other's
particles, shared scope, render on change, sounds, load scene) that build on each other's
scene state and share the CDP driver in `tools/lib/harness.mjs` — `press`, `undo`, `play`,
`object(id)`, `world()`, `mutate(js)` and friends, so a check reads like the interaction it
exercises.
@@ -107,6 +107,12 @@ and loop flag) is the witness, for the reason `renderStats` is one. The seed's t
audio file the host and the CI image are guaranteed to share, and the bytes are a pure function of
the arguments, so storage stays content-addressed and a check can name a file by its exact size.
**The one step that must clean up after itself twice.** `35-load-scene` makes a second scene and
plays *from* it, so it leaves behind both assets and a project field. Its own scene is where all of
its objects live, which keeps the shared fixture scene untouched; the start scene it names is
cleared at the end, because a step that leaves one behind would silently redirect every Play of
every step after it.
**A check that demands an exact number must be handed the number, not something it can be derived
from.** The particle preview used to hold second 0.5 by computing the clock reading that *would* mean
0.5 since the field started; the round trip through floating point misses by the last bit about one
+1
@@ -10,6 +10,7 @@ Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com
| Page | What it covers |
|---|---|
| [Concepts](Concepts) | Assets, scene objects, overrides, containers, play mode, the camera, the console, editing while playing, running without Play |
| [Scenes](Scenes) | The active scene and the start scene, the switch next to Play, `Load scene` and what a swap keeps |
| [Scene editing](Scene-editing) | Placing by drag, editor symbols, z-order, hierarchy and parenting, the editor offset |
| [Behaviors](Behaviors) | Node graphs: variables and properties, Variables assets, the node set, structs and lists, loops, behaviors as nodes, editing the graph, the live view |
| [Colliders](Colliders) | Collider assets, collision events, blocking movement |
+87
@@ -0,0 +1,87 @@
A project holds as many **scenes** as it likes. One of them is the one you are editing, one of
them is the one a game begins with, and a graph can swap the one that is running for another.
Those are three different questions, and this page is about keeping them apart.
## Active scene, start scene
The **active scene** is what the editor shows: the Scene tab draws it, the hierarchy lists it, a
dropped asset lands in it. It is set by the toolbar's **Scene** picker, by double-clicking a scene
in the Assets panel, or through *Set as active scene* in its context menu. `●` marks it in the
list.
The **start scene** is where a run begins — the title screen, the first level, whatever a player
would see first. It is named on the **project**, not on the scene asset: right-click a scene in
the Assets panel → **Set as start scene**. `▶` marks it, and the same place offers **Clear start
scene** to take the name back. A project that never names one has none, and then the two questions
have the same answer.
Which of the two Play uses is the **switch next to the Play button**:
| Switch | What Play builds its world from |
| --- | --- |
| **Start scene** (default) | The named start scene; the active one while none is named. |
| **Active scene** | Whatever scene is open in the editor, whatever the project says. |
The switch is remembered in the **browser**, not in the project: it says how *you* are working
this afternoon, not what the game does, and it must never turn up in a diff of the project file.
It is captioned with the mode it stands in — *Start scene* or *Active scene*, worded exactly like
the entries of its own menu, so that what you picked and what you read back are the same words.
Which scene that currently is stands in the **scene selector** on the left, and the start scene is
marked `▶` in the Assets panel.
The point of the two modes is that a game with a title screen is otherwise tedious to work on:
every run would begin at the title, three clicks away from the level you are editing. *Active
scene* is the mode for building, *Start scene* the mode for playing what you built.
Deleting the start scene leaves a name nothing answers to, and that reads exactly like no name at
all — the run falls back to the active scene. Undoing the deletion brings the start scene back
with it, because nothing was cleaned up behind your back.
## Load scene
**Load scene** swaps the running scene for another one, from inside a graph — a level ending, a
door, a game-over screen. Its `scene` input takes a scene the way `Play sound` takes a sound; a
**Scene** literal feeds it through an edge.
```
On collision (Exit) → Load scene (Level 2)
```
**What ends.** Everything of the old scene: its objects, the behaviors on them, and the sounds
those behaviors were playing. That last one is not a rule of its own — a voice belongs to the
behavior that started it (see [Sounds](Sounds)), and those behaviors are destroyed like everything
else in the scene. Music that should carry across a swap is started again in the new scene, or
played **detached**.
**What carries on.** What belongs to the *run* rather than to the scene:
- the values of the **Variables assets** — the score that survives the next level,
- where the **global** state machines stand (see [State machines](State-machines)),
- the **clock**: `Time` keeps counting, only a restart puts it back to zero.
A **local** machine's state does not: it belongs to a behavior instance, and that instance is
gone. Nor does any object — there are no persistent objects. Something that has to travel between
scenes travels as a shared variable.
**When it happens.** Once the frame is over, never mid-flow: the graph that asked runs to its end
first, in the world it is still standing in. Half a frame in the old world and half in the new one
is the kind of thing nobody can debug. Asked twice in one frame, the **last** ask is the one that
happens.
**Loading the scene that is already running** rebuilds it. That is *reset this level* — the same
picture as a restart, except that the shared variables are kept, which is what makes a life
counter work.
**An id naming no scene** does nothing and says so in the console, rather than tearing down the
world it is standing in half way through.
**In the editor run it is ignored** (a warning in the console). The editor run *is* the scene you
are looking at, brought to life; swapping it would mean editing a scene you are not looking at.
## Restart, stop, load — what each one keeps
| | Objects | Shared variables | Global machines | Clock | Sounds |
| --- | --- | --- | --- | --- | --- |
| **Load scene** | rebuilt | kept | kept | keeps running | end with their behavior |
| **Restart game** | rebuilt | back to declared | back to their start state | back to 0 | all end |
| **Stop** (`Esc`) | gone | discarded | discarded | back to 0 | all end |
+13 -1
@@ -36,7 +36,7 @@ so what the inspector plays is what the graph will play.
| Node | What it does |
| --- | --- |
| **Play sound** | Starts the sound named by its `sound` input. `volume` **multiplies** the asset's own volume (1 leaves it alone) and `loop` is a choice of three: *From the asset*, *On*, *Off*. |
| **Play sound** | Starts the sound named by its `sound` input. `volume` **multiplies** the asset's own volume (1 leaves it alone), `loop` is a choice of three (*From the asset*, *On*, *Off*), and `detached` says the voice belongs to nobody. |
| **Stop sound** | Silences every voice of the sound it names, however many plays started it — a loop included. Naming no sound silences everything the run is playing. |
| **Sound** | A constant sound, for either of the two to work on through an edge. |
@@ -65,6 +65,18 @@ sound starts a moment after the node ran. `Play sound` never waits on the networ
`Stop sound` that happens while the bytes are still in flight wins: the sound does not start
afterwards on its own.
**A sound belongs to the object making it.** A voice is owned by the behavior that started it, and
ends when that behavior is destroyed — a sound is something a thing in the world is *doing*, not a
thing of its own. Destroy an enemy and the engine it was running stops with it; no `Stop sound` in
an `On destroy` needed, and none forgotten. The same rule is what ends the sounds of a scene that
[Load scene](Scenes) swaps away: every behavior in it is destroyed.
**`detached` is the way out, and it exists for one shape.** An object that plays its own death
sound and destroys itself in the same flow would cut that sound off in the instant it started.
With `detached` on, the voice is nobody's: it outlives the object and plays to its end (or forever,
if it loops — then only `Stop sound` or the end of the run silences it). It is off by default,
because the owned case is the one that is almost always meant.
**The run owns its sounds.** Pausing (`Ctrl+Enter`) holds every voice where it is and resuming
carries it on mid-note. Stopping the run (`Esc`) ends every voice, looping music included —
nothing may outlive the world that started it. An editor run does the same, and is restarted by
+1
@@ -1,6 +1,7 @@
**Using the editor**
- [Concepts](Concepts)
- [Scenes](Scenes)
- [Scene editing](Scene-editing)
- [Behaviors](Behaviors)
- [Colliders](Colliders)