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.
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):
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;
}
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.
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.
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.
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.
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;
}
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.
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
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);
}
}
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));
}
}
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.
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;
}
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.
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).
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.
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();
}
}
}
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.
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.
| 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.