Scene Management

Switch between 3D scenes based on game state, not URL routing.

The pattern

Games have phases: lobby → gameplay → results → lobby. Each phase needs:

  • Different 3D scene (lobby room, game arena, scoreboard)
  • Different UI overlay (player list, HUD, final scores)
  • Different input handlers (chat only, game controls, click to continue)

Key insight: Use Svelte stores to drive scene switching, not SvelteKit’s router. In-game transitions are state changes, not navigation.


Scene store

Create client/src/lib/scene-store.ts:

import { writable, derived } from 'svelte/store';

export type GamePhase = 'lobby' | 'playing' | 'results';

export interface SceneState {
  phase: GamePhase;
  countdown: number | null;  // Seconds until game starts
  winner: string | null;     // User ID of winner
}

export const sceneState = writable<SceneState>({
  phase: 'lobby',
  countdown: null,
  winner: null
});

// Derived: Is game active?
export const isPlaying = derived(sceneState, ($scene) => $scene.phase === 'playing');

// Derived: Should show HUD?
export const showHUD = derived(sceneState, ($scene) => $scene.phase === 'playing');

export function setPhase(phase: GamePhase) {
  sceneState.update((s) => ({ ...s, phase }));
}

export function startCountdown(seconds: number) {
  sceneState.update((s) => ({ ...s, countdown: seconds }));
}

export function setWinner(userId: string) {
  sceneState.update((s) => ({ ...s, winner: userId, phase: 'results' }));
}

export function resetToLobby() {
  sceneState.set({ phase: 'lobby', countdown: null, winner: null });
}

Scene director component

Create client/src/lib/SceneDirector.svelte:

<script lang="ts">
  import { sceneState } from './scene-store';
  import LobbyScene from './scenes/LobbyScene.svelte';
  import GameplayScene from './scenes/GameplayScene.svelte';
  import ResultsScene from './scenes/ResultsScene.svelte';

  $: currentPhase = $sceneState.phase;
</script>

<!-- Render only the active scene -->
{#if currentPhase === 'lobby'}
  <LobbyScene />
{:else if currentPhase === 'playing'}
  <GameplayScene />
{:else if currentPhase === 'results'}
  <ResultsScene />
{/if}

Why this works: Svelte unmounts the old scene component and mounts the new one when currentPhase changes. Threlte canvases are cleaned up automatically.


Example: Lobby scene

Create client/src/lib/scenes/LobbyScene.svelte:

<script lang="ts">
  import { T } from '@threlte/core';
  import { Text3D, Environment } from '@threlte/extras';
  import { playerList } from '$lib/game-store';
  import { sceneState } from '$lib/scene-store';

  $: playerCount = $playerList.length;
  $: countdown = $sceneState.countdown;
</script>

<T.PerspectiveCamera makeDefault position={[0, 2, 8]} fov={50}>
  <T.OrbitControls enablePan={false} />
</T.PerspectiveCamera>

<Environment path="/hdri/" files="studio.hdr" />

<!-- Title -->
<Text3D
  text="Waiting for players..."
  position={[0, 3, 0]}
  size={0.5}
  height={0.1}
  curveSegments={8}
>
  <T.MeshStandardMaterial color="#f59e0b" />
</Text3D>

<!-- Countdown -->
{#if countdown !== null}
  <Text3D
    text="Starting in {countdown}..."
    position={[0, 1.5, 0]}
    size={0.3}
    height={0.05}
  >
    <T.MeshStandardMaterial color="#10b981" />
  </Text3D>
{/if}

<!-- Player pedestals -->
{#each $playerList as player, i}
  <T.Group position={[(i - playerCount / 2) * 2, 0, 0]}>
    <!-- Pedestal -->
    <T.Mesh position.y={0.5}>
      <T.CylinderGeometry args={[0.6, 0.6, 1, 16]} />
      <T.MeshStandardMaterial color="#3b82f6" metalness={0.3} roughness={0.4} />
    </T.Mesh>

    <!-- Player avatar (simple box for now) -->
    <T.Mesh position.y={1.5}>
      <T.BoxGeometry args={[0.4, 0.8, 0.4]} />
      <T.MeshStandardMaterial color="#f59e0b" />
    </T.Mesh>
  </T.Group>
{/each}

<!-- Ground -->
<T.Mesh rotation.x={-Math.PI / 2} position.y={-0.01}>
  <T.PlaneGeometry args={[20, 20]} />
  <T.MeshStandardMaterial color="#1f2937" />
</T.Mesh>

Example: Gameplay scene

Create client/src/lib/scenes/GameplayScene.svelte:

<script lang="ts">
  import { T, useTask } from '@threlte/core';
  import { getInterpolatedPlayers } from '$lib/interpolation';
  import { onMount } from 'svelte';

  let players = $state<Map<string, any>>(new Map());

  useTask(() => {
    const now = performance.now();
    players = getInterpolatedPlayers(now);
  });
</script>

<T.PerspectiveCamera makeDefault position={[0, 10, 10]} fov={60}>
  <T.OrbitControls />
</T.PerspectiveCamera>

<T.AmbientLight intensity={0.4} />
<T.DirectionalLight position={[10, 10, 5]} intensity={1.2} />

<!-- Game arena -->
<T.Mesh rotation.x={-Math.PI / 2} position.y={-0.5}>
  <T.PlaneGeometry args={[20, 20]} />
  <T.MeshStandardMaterial color="#0f172a" />
</T.Mesh>

<!-- Arena walls -->
<T.Mesh position={[10, 0.5, 0]}>
  <T.BoxGeometry args={[0.5, 2, 20]} />
  <T.MeshStandardMaterial color="#1e293b" />
</T.Mesh>
<T.Mesh position={[-10, 0.5, 0]}>
  <T.BoxGeometry args={[0.5, 2, 20]} />
  <T.MeshStandardMaterial color="#1e293b" />
</T.Mesh>
<T.Mesh position={[0, 0.5, 10]}>
  <T.BoxGeometry args={[20, 2, 0.5]} />
  <T.MeshStandardMaterial color="#1e293b" />
</T.Mesh>
<T.Mesh position={[0, 0.5, -10]}>
  <T.BoxGeometry args={[20, 2, 0.5]} />
  <T.MeshStandardMaterial color="#1e293b" />
</T.Mesh>

<!-- Players -->
{#each Array.from(players.values()) as player (player.userId)}
  <T.Mesh position={[player.x, 0, player.z]}>
    <T.CapsuleGeometry args={[0.3, 0.8, 8, 16]} />
    <T.MeshStandardMaterial color="#f59e0b" metalness={0.2} roughness={0.6} />
  </T.Mesh>
{/each}

Example: Results scene

Create client/src/lib/scenes/ResultsScene.svelte:

<script lang="ts">
  import { T } from '@threlte/core';
  import { Text3D } from '@threlte/extras';
  import { sceneState, resetToLobby } from '$lib/scene-store';

  $: winner = $sceneState.winner;

  function handleContinue() {
    resetToLobby();
  }
</script>

<T.PerspectiveCamera makeDefault position={[0, 2, 6]} fov={50} />

<T.AmbientLight intensity={0.8} />
<T.DirectionalLight position={[3, 5, 3]} intensity={0.6} />

<!-- Winner announcement -->
{#if winner}
  <Text3D
    text="Player {winner} wins!"
    position={[0, 2, 0]}
    size={0.6}
    height={0.1}
    curveSegments={12}
  >
    <T.MeshStandardMaterial color="#10b981" metalness={0.5} roughness={0.3} />
  </Text3D>
{/if}

<!-- Confetti (simple particles) -->
{#each Array(20) as _, i}
  <T.Mesh position={[Math.random() * 4 - 2, Math.random() * 3 + 1, Math.random() * 2 - 1]}>
    <T.BoxGeometry args={[0.1, 0.1, 0.1]} />
    <T.MeshStandardMaterial color={['#f59e0b', '#10b981', '#3b82f6'][i % 3]} />
  </T.Mesh>
{/each}

<!-- Overlay UI (outside Canvas) -->
<div class="overlay">
  <button onclick={handleContinue}>Back to Lobby</button>
</div>

<style>
  .overlay {
    position: fixed;
    bottom: 2rem;
    left: 50%;
    transform: translateX(-50%);
    z-index: 100;
  }

  button {
    padding: 1rem 2rem;
    font-size: 1.2rem;
    background: #10b981;
    color: white;
    border: none;
    border-radius: 0.5rem;
    cursor: pointer;
  }

  button:hover {
    background: #059669;
  }
</style>

Wire scene changes to server events

Update client/src/lib/game-store.ts:

import { setPhase, startCountdown, setWinner } from './scene-store';

export function joinGameRoom(socket: any, roomId: string): Promise<void> {
  return new Promise((resolve, reject) => {
    gameChannel = socket.channel(`game:${roomId}`, {});

    // Listen for game phase changes
    gameChannel.on('game_starting', (payload: any) => {
      startCountdown(payload.countdown);
    });

    gameChannel.on('game_started', () => {
      setPhase('playing');
    });

    gameChannel.on('game_ended', (payload: any) => {
      setWinner(payload.winner_id);
    });

    gameChannel.on('return_to_lobby', () => {
      resetToLobby();
    });

    // ... rest of channel setup
  });
}

Server: Send phase change events

Update server/lib/tostada/game/game_server.ex:

# In handle_info(:tick, state) or wherever game logic lives
defp check_game_end(room_state) do
  # Example: first player to reach goal wins
  winner = Enum.find(room_state.players, fn {_id, player} ->
    player[:score] >= 10
  end)

  if winner do
    {winner_id, _} = winner
    PubSub.broadcast(@pubsub, "game:#{room_state.room_id}", {:game_ended, winner_id})
  end
end

In the channel:

@impl true
def handle_info({:game_ended, winner_id}, socket) do
  push(socket, "game_ended", %{winner_id: winner_id})
  {:noreply, socket}
end

Loading 3D models per scene

If you’re using the model pipeline addon:

<script lang="ts">
  import { GLTF } from '@threlte/extras';
  import { onMount } from 'svelte';

  let modelUrl = '/models/lobby_room.glb';
</script>

<GLTF url={modelUrl} />

Models are lazy-loaded when the scene component mounts. When unmounted, Threlte cleans up the GPU resources.


Preloading assets

To avoid loading delays on scene transition:

import { preloadGLTF } from '@threlte/extras';

export async function preloadGameAssets() {
  await Promise.all([
    preloadGLTF('/models/lobby_room.glb'),
    preloadGLTF('/models/game_arena.glb'),
    preloadGLTF('/models/trophy.glb')
  ]);
}

Call this during initial load or while showing a loading screen.


Main app structure

Update client/src/routes/+page.svelte:

<script lang="ts">
  import { Canvas } from '@threlte/core';
  import SceneDirector from '$lib/SceneDirector.svelte';
  import HUD from '$lib/HUD.svelte';
  import { showHUD } from '$lib/scene-store';
</script>

<!-- Full-screen Canvas -->
<Canvas>
  <SceneDirector />
</Canvas>

<!-- Overlay UI -->
{#if $showHUD}
  <HUD />
{/if}

<style>
  :global(body) {
    margin: 0;
    overflow: hidden;
  }
</style>

The SceneDirector handles all 3D rendering, HUD shows game UI on top.


Scene transitions with animations

Add fade transitions:

<script lang="ts">
  import { fade } from 'svelte/transition';
  import { sceneState } from './scene-store';

  $: currentPhase = $sceneState.phase;
</script>

{#if currentPhase === 'lobby'}
  <div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
    <LobbyScene />
  </div>
{:else if currentPhase === 'playing'}
  <div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
    <GameplayScene />
  </div>
{:else if currentPhase === 'results'}
  <div in:fade={{ duration: 300 }} out:fade={{ duration: 300 }}>
    <ResultsScene />
  </div>
{/if}

Debugging scene state

Browser console:

import { sceneState } from '$lib/scene-store';

// Check current phase
sceneState.subscribe(console.log);

// Manually change phase
import { setPhase } from '$lib/scene-store';
setPhase('playing');

Performance tips

  1. Limit active lights: Each scene should have ≤3 real-time lights (ambient + 2 directional)
  2. Use InstancedMesh: If showing 100+ identical objects (coins, particles)
  3. Dispose geometries: Threlte handles this automatically on unmount
  4. Texture atlases: Combine small textures into one large texture
  5. Level of Detail (LOD): Swap detailed models for simple ones when far from camera

Next steps

  • Putting it together: Build a complete game using all patterns
  • Advanced scene techniques: Post-processing, physics integration, particle systems
  • Asset optimization: Texture compression, GLTF draco compression, mesh simplification

Scene management completes the core multiplayer stack. You now have channels, authority, ticks, input, and scenes.