In NacatamalOn a scene is a function. This page tells what the engine does with it: when it runs it, what happens every frame, what pausing it means, how you switch from one to another and what gets cleaned up when it leaves. Every step is read from the engine's code, in the order it happens.
The states of a scene
- Registered. Each key of the object you hand the game is the name of a scene. Registered means it exists and can be started by its name, nothing more.
- Being born. The engine runs the function's body once. That is where everything in the scene is declared.
- Running. Its
useUpdatecallbacks run every frame, it reacts to clicks and it is drawn. - Paused. It is still drawn, but its
useUpdatecallbacks do not run and it does not react to clicks. It is what a pause menu over a frozen level needs. - Held. Only happens to the scene coming in with a transition: it has been born and is loading what it asked for, but it is not seen, not updated and not clicked until the swap.
- Stopped. It leaves the list of running scenes, stops being drawn and its cleanups run,
its
useSceneUnmountamong them (further down, in "Leaving"). If it is started again, it starts from scratch: the body runs again.
Being born: the body runs once
const Level = () => {
// All of this runs ONCE, when the scene starts.
const hero = useLoadTexture({ src: '/hero.png' });
const player = createSprite({ texture: hero, transform: { x: 160, y: 120 } });
const keys = useKeyboard();
// This does not run now: it is registered, and the engine calls it every frame.
useUpdate((dt) => {
if (keys.isDown('ArrowRight')) player.transform.x += 120 * dt;
});
return createScene();
};Three things about this moment matter:
- It is synchronous. The body cannot be
asyncor wait for anything. A scene that returns a promise is refused with an error that says so. - Hooks are called here and only here.
useUpdate,useKeyboardoruseLoadTextureattach to the scene being born. Outside the body (in a click, in a timer) no scene is being born. To create objects later, the body prepares a factory withuseSpawn, and that factory can be called from auseUpdateor from a click. - Loading does not block.
useLoadTexturestarts loading and hands the texture back at once, still empty. The scene starts anyway, and the sprite appears as soon as the image lands. For a progress bar there isuseLoader.
At the end, createScene() closes the scene with everything declared and the engine adds it to
the list of running scenes.
The game's first scene is the first key of the object, or the one you name:
const game = createGame('#app', { width: 320, height: 240 });
game({ Menu, Level }); // starts Menu
game({ Menu, Level }, 'Level'); // starts LevelOne frame, in order
Sixty times a second, in this order:
- 1Inputalso while paused
The clicks and touches that arrived since the last frame are handed to their listeners, and the gamepad is read. It happens here, at a moment the game chooses, and not whenever the browser fires them.
- 2Transitionalso while paused
If a scene change with a transition is under way, it moves on. It never freezes: the screen always ends up uncovered.
- 3Updates
Each running scene, in the order it was launched, runs its useUpdate callbacks with dt. Paused and held scenes are skipped.
- 4Keyboard closesalso while paused
justPressed stops being true. A press counts for a single frame, and in that frame it counts for everyone who asks.
- 5Removalsalso while paused
What was destroyed with destroy leaves now, while nobody is walking the scenes. It is never drawn one last time.
- 6Soundalso while paused
The listener and the sounds placed in the world follow what is on screen.
- 7Drawingalso while paused
Where everything is gets worked out and the frame is painted, scene by scene in the order they were launched.
dt is the seconds since the previous frame. It has two adjustments worth knowing:
- It is capped at 0.25 seconds. If the browser stalls (you switch tabs and come back), the game does not jump several seconds at once.
- It is game time. It is multiplied by
timeScale(slow motion) and is0while the game is paused.
If a useUpdate throws, the rest of its scene's updates for that frame are lost, nothing
more: the other scenes carry on and the frame is drawn. The game reports it with its error
event.
Inside the scene: who goes first
A scene is a tree of objects, walked from the top down: first the object, then its children, branch by branch. So when the sword updates, the player it hangs from has already moved this frame.
Two details that head off strange bugs:
- What is born in a frame waits for the next. A bullet does not travel on the frame it is fired, and something that creates objects cannot chain them all inside a single frame.
- What is destroyed stops on the spot.
destroystops updating it at once, even though it only leaves at the removals step. A bullet cannot report a second hit after the one that killed it.
Pausing is not stopping
| Paused | Stopped | |
|---|---|---|
Its useUpdate | does not run | does not run |
| Drawn | yes | no |
| Its clicks | does not react | does not react |
useSceneUnmount | does not run | runs, once |
| How it comes back | resume(), where it was | by starting it again, from scratch |
A pause menu is exactly both at once: the level paused, still on screen, and a menu scene launched on top.
const Level = () => {
const scene = useScene();
const keys = useKeyboard();
useUpdate(() => {
if (keys.justPressed('Escape')) {
scene.pause(); // the level freezes, but stays on screen
scene.launch('PauseMenu'); // and the menu is drawn on top
}
});
return createScene();
};
const PauseMenu = () => {
const scene = useScene();
const keys = useKeyboard();
useUpdate(() => {
if (keys.justPressed('Escape')) {
scene.resume('Level'); // the level carries on where it was
scene.stop(); // and the menu goes away
}
});
return createScene();
};The level cannot unpause itself: while paused, its useUpdate callbacks do not run. That is why
the menu does it.
Changing scene
scene.change('Level2') replaces the scene that calls it with another one. There are two ways.
Rules worth knowing:
- Only the first call counts. The condition that triggers a change usually stays true for several frames, and each of them would start another copy. The engine ignores the rest.
- One change at a time. While a transition is under way, another
changeis ignored with a warning. - Changing to its own name restarts it, always with a hard cut: covering it would need two copies of the same scene at once.
- The transition runs on its own clock. Neither pausing nor
timeScaleslows it down: slow motion cannot stretch a 300 ms fade into three seconds.
Several scenes at once
scene.launch('Map') starts another scene alongside the ones already running, without
stopping any. Each has its own updates, its own pause and its own cleanups, and the one launched
later is drawn on top.
const Level = () => {
const scene = useScene();
const keys = useKeyboard();
let mapOpen = false;
// M opens the map over the level, and closes it.
useUpdate(() => {
if (keys.justPressed('m')) {
if (mapOpen) scene.stop('Map');
else scene.launch('Map');
mapOpen = !mapOpen;
}
});
return createScene();
};A scene can only be running once: launching a name that is already running is an error. And
scene.stop() with no name stops the scene that calls it, like a menu closing on its own button.
Leaving: cleaning up what is not the engine's
When a scene stops (with stop, with change, or because the whole game is destroyed), the
engine walks its tree and runs its cleanups, children before parents: that way a sword's
cleanup can still reach the player it hung from.
What the engine gave you goes away with the scene by itself: its sprites, its useUpdate
callbacks, its listeners, its signals. Loaded images are kept, so the next scene that asks for
them does not download them again.
What you started outside the engine stays alive unless you stop it: a setInterval, a
listener on window, a request you want to abort. That is what useSceneUnmount is for. It runs
once when the scene leaves (and not when it is paused):
const Level = () => {
const onKey = (event: KeyboardEvent) => console.log(event.key);
window.addEventListener('keydown', onKey);
// Without this, the listener stays alive after the level ends and fires in the next scene.
useSceneUnmount(() => window.removeEventListener('keydown', onKey));
return createScene();
};The whole game
Above the scenes sits the game, with its own beginning and its own end:
createGame('#app', { … })keeps the settings and returns the function that starts it.- Called with the scenes, the engine picks what to draw with (WebGPU or, failing that, WebGL2).
- It registers each scene under its name.
- It starts the initial scene: its body runs.
- It draws the first frame, with
dtequal to0. - It announces itself with its
readyevent.
Pausing the game is not pausing a scene. useGame().pause() freezes the whole game: no scene
updates and dt is 0, but everything is still drawn and input is still read. The two pauses are
independent: resuming the game does not resume a scene paused on its own, nor the other way round.
At the end, destroy() stops every scene (with all their cleanups) and the game announces its
destroy event.
In one table
| What | When it runs |
|---|---|
| The scene's body | Once, when it starts |
useUpdate | Every frame, while the scene is running |
useSceneUnmount | Once, when the scene leaves (not when it is paused) |
scene.change | Cut: at once. With a transition: the old one leaves at the swap |
scene.launch | At once, alongside the others and drawn on top |
scene.pause / resume | At once; the scene is still drawn |
destroy(object) | Stops updating at once, and leaves at the end of the frame |
The game's ready event | After the first frame |