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
- Limit active lights: Each scene should have ≤3 real-time lights (ambient + 2 directional)
- Use InstancedMesh: If showing 100+ identical objects (coins, particles)
- Dispose geometries: Threlte handles this automatically on unmount
- Texture atlases: Combine small textures into one large texture
- 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.