SceneManager

SceneManager organizes your game into discrete scenes (menus, levels, HUDs) with lifecycle management, stack-based navigation, and visual transitions. Place it inside Game and register scenes with Scene.

tsx
import { SceneManager } from "@carverjs/core/components";

Quick Start #

tsx
import { Game, SceneManager, Scene, World, Actor } from "@carverjs/core/components";
import { useScene } from "@carverjs/core/hooks";

function MenuScene() {
  const { go } = useScene();
  return (
    <World>
      <Actor
        type="primitive" shape="box" color="#facc15"
        onClick={() => go("gameplay", { level: 1 }, { type: "fade", duration: 0.8 })}
      />
    </World>
  );
}

function GameplayScene({ data }: { data?: { level: number } }) {
  return (
    <World>
      <Actor type="primitive" shape="sphere" color="#22c55e" />
    </World>
  );
}

function App() {
  return (
    <Game mode="2d">
      <SceneManager initial="menu">
        <Scene name="menu" component={MenuScene} />
        <Scene name="gameplay" component={GameplayScene} />
      </SceneManager>
    </Game>
  );
}

Props Reference #

PropTypeDefaultDescription
initialstringRequired. Name of the scene to start on mount
loadingFallbackReactNodenullFallback shown while lazy-loaded scenes are loading (must be a Three.js element)
persistentReactNodeElements rendered above all scenes, surviving transitions (e.g., global audio wrapper)
childrenReactNode<Scene> components for registration

Scene navigation is stack-based with four operations:

MethodBehaviorPrevious Scene
go(name)Clear the stack, navigate to sceneDestroyed (or sleeping if persistent)
push(name)Push scene on top of stackSleeping
pop()Remove top scene, return to previousDestroyed (popped scene)
replace(name)Swap top of stackDestroyed (or sleeping if persistent)

Use the useScene hook or the imperative getSceneManager() API.

Example: Pause menu with push/pop #

tsx
function GameplayScene() {
  const { push } = useScene();
  const { isActionJustPressed } = useInput({ actions: { pause: ["Escape"] } });

  useGameLoop(() => {
    if (isActionJustPressed("pause")) push("pause");
  });

  return <World>{/* game content */}</World>;
}

function PauseScene() {
  const { pop } = useScene();
  return (
    <group>
      <Actor type="primitive" shape="plane" color="rgba(0,0,0,0.5)" />
      <Actor type="primitive" shape="box" color="green" onClick={() => pop()} />
    </group>
  );
}

Transitions #

Pass a TransitionConfig as the third argument to any navigation method:

tsx
go("gameplay", { level: 1 }, { type: "fade", duration: 1, color: "#000000" });
TypeBehavior
"none"Instant swap (default)
"fade"Fade to color at midpoint, swap scenes, fade from color
"custom"User-provided GLSL fragment shader with uProgress uniform (0-1)

Default transition per scene #

Set a default transition on the <Scene> config. It applies when navigating to that scene without an explicit transition:

tsx
<Scene
  name="gameplay"
  component={GameplayScene}
  transition={{ type: "fade", duration: 0.5 }}
/>

Custom shader transition #

tsx
go("credits", undefined, {
  type: "custom",
  duration: 1.5,
  fragmentShader: `
    uniform float uProgress;
    void main() {
      float circle = length(gl_FragCoord.xy / vec2(800.0, 600.0) - 0.5);
      float alpha = step(circle, uProgress);
      gl_FragColor = vec4(0.0, 0.0, 0.0, alpha);
    }
  `,
});

Scene Lifecycle #

StatusMountedVisibleUpdatesDescription
createdNoNoNoRegistered, not started
preloadingYesNoNoLazy loading via Suspense
runningYesYesYesActive scene
pausedYesYesNoVisible but updates disabled
sleepingYesNoNoHidden, preserves React tree + GPU resources
shutting_downYesYesNoTransitioning out
destroyedNoNoNoUnmounted

Sleep vs Destroy #

tsx
<Scene name="hud" component={HUDScene} persistent />

Shared Data #

Pass data between scenes without prop drilling:

tsx
// In scene A
const { setShared } = useSceneData();
setShared("playerName", "Hero");
setShared("score", 100);

// In scene B
const { getShared } = useSceneData();
const name = getShared<string>("playerName"); // "Hero"

Lazy Loading #

Code-split scenes with the loader prop:

tsx
<Scene
  name="gameplay"
  loader={() => import("./scenes/GameplayScene")}
  transition={{ type: "fade", duration: 0.5 }}
/>

The scene's JavaScript bundle loads on-demand when navigated to. A loadingFallback renders during loading:

tsx
<SceneManager initial="menu" loadingFallback={<LoadingSpinner />}>
  {/* scenes */}
</SceneManager>

Error Isolation #

Each scene is wrapped in its own error boundary. If a scene crashes, other scenes and the transition system continue running. The crashed scene is marked as destroyed.


Architecture #

text
SceneManager
  ├── Scene (registration only, renders null)
  ├── SceneTransitionFlush  — advances transition at priority -15
  ├── SceneRenderer[]       — one per active/sleeping scene
  │     ├── <group visible={...}>
  │     ├── SceneErrorBoundary
  │     ├── Suspense
  │     ├── SceneContent    — resolves component/loader
  │     └── SceneUpdateRunner — wires onUpdate to useGameLoop
  ├── TransitionOverlay     — fullscreen fade quad (renderOrder 9999)
  └── persistent            — user-provided persistent elements

Type Definitions #

See Types for SceneManagerProps, SceneConfig, SceneStatus, TransitionConfig, TransitionType, SceneStoreState, SceneEntry, and TransitionState.