Projects bibleweb Docs atmosphere-scene-architecture.md

Scene Management Architecture & Bible Passage-to-Scene Mapping

Last modified March 29, 2026

Scene Management Architecture & Bible Passage-to-Scene Mapping

Overview

This document covers the architecture for managing multiple atmospheric scenes in a SvelteKit Bible study app, mapping Bible passages to visual environments. The reference implementation in the MonoGame desktop app uses two primary scenes — Sea of Galilee (night) and Jerusalem (sunset) — which serve as the starting point for a extensible, config-driven system.


1. Scene Management Patterns for 2D Web

State Machine as Scene Manager

The most robust pattern for 2D scene management is a finite state machine (FSM) where each scene is a state. Transitions between states are explicit, typed, and reversible. Key advantages from game programming literature (Game Programming Patterns, Nystrom):

  • Only one scene can be active at a time — prevents impossible combinations like two backgrounds rendering simultaneously
  • Entry/exit hooks initialize and clean up resources deterministically
  • A pushdown automaton variant allows "stacking" scenes (e.g., a temporary overlay scene that returns to the previous one)

For our use case, the relevant extension is Concurrent State Machines: one FSM tracks which Bible scene is active (Sea of Galilee, Jerusalem, Desert, etc.), while a second independent machine tracks reader progress / atmosphere intensity. This avoids state explosion.

type SceneId = "sea-of-galilee" | "jerusalem" | "desert" | "temple" | "garden";

type SceneState =
  | { status: "idle"; sceneId: SceneId }
  | { status: "transitioning"; from: SceneId; to: SceneId; progress: number }
  | { status: "active"; sceneId: SceneId };

interface SceneTransition {
  from: SceneId;
  to: SceneId;
  durationMs: number;
  easing: EasingFn;
}

Scene Graph for Layer Composition

Within each scene, a scene graph organizes visual layers as a tree. Nodes are rendered back-to-front (painter's algorithm). Each node is a renderable layer with its own update/draw cycle:

interface SceneNode {
  id: string;
  zIndex: number;
  visible: boolean;
  opacity: number;
  update(dt: number, params: SceneParams): void;
  draw(ctx: CanvasRenderingContext2D): void;
}

For Bible study atmospheres, the scene graph maps directly to visual depth: sky → distant mountains → water/hills → shore/city → foreground elements → particles → vignette.

Entity-Component System (ECS) — When to Use

ECS (as in Bevy, Amethyst) is well-suited for games with many heterogeneous entities. For our use case — a handful of rich, persistent background scenes — it adds more overhead than value. The layered scene graph pattern is simpler and sufficient.


2. Bible Passage Metadata & Scene Mapping

Book Categorization

Bible books map to scene contexts based on genre and narrative content:

Category Books Default Scene
Gospels Matthew, Mark, Luke, John Sea of Galilee (night)
Passion Narrative Matt 26–28, Mark 14–16, Luke 22–24, John 18–21 Jerusalem (sunset)
Acts Acts Jerusalem (sunset)
Major Prophets Isaiah, Jeremiah, Lamentations, Ezekiel, Daniel Jerusalem (sunset)
Minor Prophets Hosea–Malachi (12 books) Desert
Revelation Revelation Jerusalem (sunset)
Psalms / Wisdom Job, Psalms, Proverbs, Ecclesiastes, Song of Songs TBD (Garden / Temple)
Pentateuch Genesis–Deuteronomy Desert
History (OT) Joshua–Esther Jerusalem (sunset)
Paul's Letters Romans–Philemon Neutral / Jerusalem
General Epistles Hebrews–Jude Neutral

The Passion Narrative chapters occupy the final third of the Synoptic Gospels. Mark devotes ~5 chapters (12–16) to the final week; Matthew's Passion is chapters 26–28; Luke 22–24; John 18–21. These override the default Gospel scene.

Data-Driven Mapping

The mapping should be fully data-driven so adding a new scene only requires adding config entries, not code:

interface PassageRange {
  bookId: string;      // "MAT", "MRK", etc.
  chapterStart: number;
  chapterEnd?: number; // inclusive; omit = end of book
}

interface SceneMapping {
  sceneId: SceneId;
  priority: number;    // higher priority wins on overlap
  ranges: PassageRange[];
}

const SCENE_MAPPINGS: SceneMapping[] = [
  {
    sceneId: "jerusalem",
    priority: 10, // Passion override takes precedence
    ranges: [
      { bookId: "MAT", chapterStart: 26, chapterEnd: 28 },
      { bookId: "MRK", chapterStart: 11, chapterEnd: 16 },
      { bookId: "LUK", chapterStart: 19, chapterEnd: 24 },
      { bookId: "JHN", chapterStart: 12, chapterEnd: 21 },
    ],
  },
  {
    sceneId: "sea-of-galilee",
    priority: 5,
    ranges: [
      { bookId: "MAT", chapterStart: 1 },
      { bookId: "MRK", chapterStart: 1 },
      { bookId: "LUK", chapterStart: 1 },
      { bookId: "JHN", chapterStart: 1 },
    ],
  },
  {
    sceneId: "jerusalem",
    priority: 5,
    ranges: [
      { bookId: "ISA", chapterStart: 1 },
      { bookId: "JER", chapterStart: 1 },
      { bookId: "EZK", chapterStart: 1 },
      { bookId: "REV", chapterStart: 1 },
    ],
  },
  // ... additional mappings
];

function resolveScene(bookId: string, chapter: number): SceneId {
  const matches = SCENE_MAPPINGS.filter(m =>
    m.ranges.some(r =>
      r.bookId === bookId &&
      chapter >= r.chapterStart &&
      chapter <= (r.chapterEnd ?? 999)
    )
  );
  if (matches.length === 0) return "jerusalem"; // fallback
  return matches.sort((a, b) => b.priority - a.priority)[0].sceneId;
}

This resolver is a pure function — easy to unit test, cacheable, and trivially extensible.


3. Scene Transition Techniques

Crossfade via Canvas Alpha

The established canvas crossfade technique renders both the outgoing and incoming scenes on separate off-screen canvases, then composites them onto the visible canvas with complementary alpha values:

interface TransitionState {
  from: OffscreenCanvas;
  to: OffscreenCanvas;
  progress: number;   // 0 → 1
  durationMs: number;
  elapsed: number;
  easing: EasingFn;
}

function renderTransition(
  ctx: CanvasRenderingContext2D,
  state: TransitionState
): void {
  const t = state.easing(state.progress);
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  ctx.globalAlpha = 1 - t;
  ctx.drawImage(state.from, 0, 0);
  ctx.globalAlpha = t;
  ctx.drawImage(state.to, 0, 0);
  ctx.globalAlpha = 1;
}

Parameter Interpolation

Rather than only crossfading pixels, interpolating scene parameters produces smoother atmospheric transitions. Colors, light positions, and particle counts are lerped independently:

type EasingFn = (t: number) => number;

// Standard linear interpolation
const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t;

// Color interpolation in RGB space
function lerpColor(a: RGBColor, b: RGBColor, t: number): RGBColor {
  return {
    r: Math.round(lerp(a.r, b.r, t)),
    g: Math.round(lerp(a.g, b.g, t)),
    b: Math.round(lerp(a.b, b.b, t)),
  };
}

// Easing: ease-in-out cubic for scene transitions
const easeInOutCubic: EasingFn = (t) =>
  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;

For the MonoGame reference (1.5s crossfade), interpolating sky gradient stops, particle emitter emission rates, and light intensity independently at each frame produces a richer blend than a raw pixel crossfade.

Transition Event Flow

onPassageChange(bookId, chapter)
  → resolveScene(bookId, chapter) → newSceneId
  → if newSceneId !== currentSceneId:
      → preloadScene(newSceneId)  // ensure next scene is ready
      → beginTransition(currentSceneId, newSceneId, 1500ms)
      → [tick loop updates TransitionState.progress each frame]
      → onTransitionComplete: swap active scene, release old scene resources

4. Preloading and Caching

In-Memory Scene Cache

Background scenes consist of procedurally generated or baked canvas content. The primary caching strategy is keeping the N most recently used scenes in memory as OffscreenCanvas objects:

class SceneCache {
  private cache = new Map<SceneId, OffscreenCanvas>();
  private maxEntries: number;

  constructor(maxEntries = 3) {
    this.maxEntries = maxEntries;
  }

  get(sceneId: SceneId): OffscreenCanvas | undefined {
    return this.cache.get(sceneId);
  }

  set(sceneId: SceneId, canvas: OffscreenCanvas): void {
    if (this.cache.size >= this.maxEntries) {
      // Evict oldest entry (insertion-order iteration)
      const oldest = this.cache.keys().next().value;
      this.cache.delete(oldest);
    }
    this.cache.set(sceneId, canvas);
  }
}

Predictive Preloading

Since Bible reading is sequential, the next scene can be predicted and preloaded before the reader reaches it. At chapter boundaries, compute the scene for the next chapter and begin rendering it off-screen:

async function preloadAdjacentScenes(
  bookId: string,
  chapter: number
): Promise<void> {
  const nextChapter = chapter + 1;
  const nextSceneId = resolveScene(bookId, nextChapter);
  const currentSceneId = resolveScene(bookId, chapter);

  if (nextSceneId !== currentSceneId && !sceneCache.get(nextSceneId)) {
    // Kick off background render on next idle frame
    requestIdleCallback(() => renderSceneToCache(nextSceneId));
  }
}

IndexedDB for Baked Assets

For heavier static assets (tileable terrain textures, sprite sheets), IndexedDB is the right storage tier. Unlike the browser HTTP cache (which can be evicted), IndexedDB provides durable client-side storage. The pattern used by Babylon.js for WebGL asset management applies directly: serialize JSON scene descriptors and PNG/blob textures into a versioned IndexedDB store. On revisit, check the version key; if current, skip network fetch.

For procedurally generated scenes (pure canvas drawing, no external images), in-memory caching is sufficient — IndexedDB overhead is not warranted unless expensive Perlin noise terrain is pre-baked.


5. Configuration-Driven Scene Definitions

Scene Config Schema

Scenes are defined as pure data objects, with all visual parameters captured in a typed config. No scene-specific code lives in the renderer — only the config differs between scenes:

interface GradientStop {
  position: number;  // 0–1
  color: RGBColor;
}

interface LightSource {
  x: number;         // normalized 0–1
  y: number;
  radius: number;
  color: RGBColor;
  intensity: number;
}

interface ParticleEmitterConfig {
  emissionRate: number;    // particles per second
  lifetime: [number, number]; // [min, max] ms
  speed: [number, number];
  angle: [number, number]; // degrees
  size: [number, number];
  color: RGBColor;
  alpha: [number, number];
  gravity: number;
}

interface TerrainLayerConfig {
  type: "hills" | "mountains" | "water" | "shoreline" | "buildings";
  color: RGBColor;
  heightScale: number;
  noiseFrequency: number;
  noiseOctaves: number;
  yOffset: number;          // fraction of canvas height
  animated: boolean;
  animationSpeed?: number;
}

interface SceneConfig {
  id: SceneId;
  label: string;
  timeOfDay: "dawn" | "day" | "dusk" | "night";
  skyGradient: GradientStop[];
  terrain: TerrainLayerConfig[];
  lights: LightSource[];
  particles: ParticleEmitterConfig[];
  vignette: { intensity: number; color: RGBColor };
  ambientLight: RGBColor;
}

Example: Sea of Galilee Config

const SEA_OF_GALILEE: SceneConfig = {
  id: "sea-of-galilee",
  label: "Sea of Galilee (Night)",
  timeOfDay: "night",
  skyGradient: [
    { position: 0.0, color: { r: 15, g: 10, b: 40 } },   // deep purple zenith
    { position: 0.5, color: { r: 30, g: 20, b: 60 } },
    { position: 1.0, color: { r: 10, g: 15, b: 35 } },   // horizon
  ],
  terrain: [
    { type: "mountains", color: { r: 20, g: 15, b: 45 }, heightScale: 0.3,
      noiseFrequency: 0.004, noiseOctaves: 4, yOffset: 0.4, animated: false },
    { type: "water", color: { r: 10, g: 30, b: 70 }, heightScale: 0.02,
      noiseFrequency: 0.02, noiseOctaves: 2, yOffset: 0.65, animated: true, animationSpeed: 0.5 },
  ],
  lights: [
    { x: 0.75, y: 0.15, radius: 0.08, color: { r: 220, g: 220, b: 180 }, intensity: 0.9 }, // moon
    { x: 0.15, y: 0.82, radius: 0.03, color: { r: 255, g: 140, b: 60 }, intensity: 0.7 },  // campfire
  ],
  particles: [
    { emissionRate: 2, lifetime: [3000, 8000], speed: [0.1, 0.3],
      angle: [270, 360], size: [1, 2], color: { r: 255, g: 255, b: 200 },
      alpha: [0.4, 0.9], gravity: 0 }, // stars/ambient particles
  ],
  vignette: { intensity: 0.6, color: { r: 0, g: 0, b: 20 } },
  ambientLight: { r: 20, g: 25, b: 60 },
};

This structure means adding a new "Desert" scene is purely a data exercise — no renderer changes needed.


6. Dynamic Scene Parameters

Time-of-Day Variations

Each scene can have subtle time-of-day variations driven by reader session time or explicit chapter mood metadata. The config stores a base state; a modifier function blends toward a variant:

interface TimeOfDayVariant {
  label: string;
  skyTint: RGBColor;      // additive tint applied to base gradient
  lightIntensityMod: number; // multiplier on all light sources
  particleRateMod: number;   // multiplier on all emitter rates
}

interface SceneConfig {
  // ... existing fields ...
  timeVariants?: Record<string, TimeOfDayVariant>;
}

For the Jerusalem sunset scene, a "late dusk" variant would darken the sky tint and reduce sun intensity, representing deeper into the Passion narrative (crucifixion at the ninth hour).

Reader Progress Affecting Atmosphere

Atmosphere intensity can reflect how deep a reader is within a book. Define a 0–1 progress scalar that modulates particle density and vignette intensity:

function applyProgressModifier(
  config: SceneConfig,
  progress: number // 0 = book start, 1 = book end
): SceneConfig {
  const intensityBoost = progress * 0.3; // max 30% increase
  return {
    ...config,
    vignette: {
      ...config.vignette,
      intensity: Math.min(1.0, config.vignette.intensity + intensityBoost),
    },
    particles: config.particles.map(p => ({
      ...p,
      emissionRate: p.emissionRate * (1 + intensityBoost),
    })),
  };
}

This creates a subtle atmospheric build as the reader progresses — the Sea of Galilee scene gains particle density through the middle Gospel chapters, and the Jerusalem sunset intensifies as the Passion narrative deepens.


7. Scene Composition via Layers

Layer Stack Architecture

Drawing order follows the painter's algorithm — each layer draws on top of the previous. The scene renderer iterates through a defined stack, calling each layer's draw method with the current canvas context and scene parameters:

type LayerType =
  | "sky-gradient"
  | "celestial"       // sun, moon, stars
  | "far-mountains"
  | "near-mountains"
  | "water"
  | "shoreline"
  | "buildings"
  | "foreground"
  | "particles"
  | "vignette";

interface RenderLayer {
  type: LayerType;
  config: TerrainLayerConfig | LightSource | ParticleEmitterConfig | object;
  draw(ctx: CanvasRenderingContext2D, params: SceneParams, dt: number): void;
}

A SceneRenderer receives a SceneConfig and constructs the layer stack at initialization:

class SceneRenderer {
  private layers: RenderLayer[] = [];

  constructor(config: SceneConfig) {
    this.layers = buildLayerStack(config);
  }

  render(ctx: CanvasRenderingContext2D, dt: number, params: SceneParams): void {
    for (const layer of this.layers) {
      ctx.save();
      layer.draw(ctx, params, dt);
      ctx.restore();
    }
  }
}

Multiple Canvas Strategy for Performance

IBM developer guidance (and practical game engine usage) recommends using separate <canvas> elements for different rendering frequencies:

Canvas Layers Update Frequency
Static background Sky gradient, mountains Only on scene change
Dynamic mid Water animation, light shafts Every frame (60fps)
Particles Particle effects, campfire Every frame
UI overlay Vignette (static) Only on resize

This avoids re-drawing expensive terrain on every frame when only the water ripple changes. In practice, for our scale (2–3 scenes), a single canvas with a dirty-flag on static layers is sufficient, reserving full multi-canvas only if frame time budget demands it.

Canvas Layering in SvelteKit

In Svelte 5, canvas layers are declarative components that stack via CSS position: absolute:

<div class="scene-container" style="position: relative">
  <canvas bind:this={bgCanvas}  class="layer" style="z-index: 1" />
  <canvas bind:this={midCanvas} class="layer" style="z-index: 2" />
  <canvas bind:this={fxCanvas}  class="layer" style="z-index: 3" />
</div>

Each canvas component owns its layer's render loop, keeping concerns cleanly separated and allowing independent performance profiling.


Summary: Architecture Decisions

Concern Recommended Pattern
Scene switching FSM with explicit transition states
Passage → scene lookup Priority-sorted SCENE_MAPPINGS array, pure resolver function
Scene definition Typed SceneConfig object — zero code per new scene
Crossfade Dual off-screen canvas, alpha composite with eased t
Parameter blending Per-parameter lerp during transition, easeInOutCubic
Layer composition Ordered RenderLayer[] stack, painter's algorithm
Caching LRU in-memory OffscreenCanvas cache (3 entries)
Preloading requestIdleCallback on chapter change for adjacent scenes
Dynamic atmosphere Progress scalar → modifier function on base config
Multi-canvas Single canvas with dirty flags (scale up to split canvases if needed)

The design is intentionally minimal at the start — two scenes, one resolver, one renderer — and scales to 10+ scenes purely through data (additional SceneConfig objects and SceneMapping entries), with no architectural changes.


Sources