If you chose to do things not because they are easy, but because they are hard, you’ve likely crashed into the Moon multiple times playing the Firefly lander demo on the Veenie Kit page.
(Moar right rudder next time. There’s never enough right rudder.)
And if you also asked “How in tarnation is this running at 60fps while also responding to scroll events?”, or opened DevTools without knowing you can hijack the mission by typing missionLayout.setPreset('full'), this post might well be for you.
Let’s dissect the Veenie architecture as surgically as SvelteKit updates the DOM to let pylotes crash on Luna without crashing the browser. Because SvelteKit is Veenie’s beating heart, alongside the marvel of engineering known as Threlte.
Runes mode activated. Let’s get codey.
The Problem: Physics + Narrative + Performance
Building interactive space simulations for the web is a triangle of competing constraints:
- Real Physics - RK4 integrators, quaternion rotations, atmospheric models
- Narrative Control - The sim should tell a story as the user scrolls
- Browser Performance - Can’t recalc styles on every pixel scroll
Most “interactive infographics” pick two. Either:
- Real physics but no narrative (KSP web demos)
- Narrative but fake physics (scrollytelling sites)
- Physics + narrative but dogshit performance (Unity WebGL exports)
Veenie picks all three. Here’s how.
The Stack (What You’re Actually Running)
User's Browser
├── SvelteKit 5 (reactive state, SSR)
├── Threlte (Three.js wrapper for Svelte)
├── Custom Physics Engine (pure TypeScript)
└── Singleton Services (time, physics, layout) The key insight: Separate narrative choreography from physics simulation.
The page controls when things happen. The physics controls what happens. Neither blocks the other.
Part 1: The Singleton Pattern (No More Context Hell)
Old Way (Venus Sim, Pre-Refactor):
// In parent component
const timeCtx = createTimeContext();
setTimeContext(timeCtx);
const bridge = createPhysicsBridge(timeCtx);
setPhysicsContext(bridge);
// In child components
const physics = getPhysicsContext(); // Throws if not in tree Problems:
- Setup ceremony in every sim
- Components must be in Svelte component tree
- Can’t use physics in utility functions
- Hard to debug (which instance am I looking at?)
New Way (Firefly, Post-Refactor):
// context/physics-bridge.svelte.ts
class FireflyLanderPhysicsBridge {
constructor() {
this.engine = new FireflyLanderEngine();
// Auto-subscribe to time service
timeService.subscribe((dt) => this.step(dt));
}
}
// Export singleton
export const missionPhysics = new FireflyLanderPhysicsBridge(); Now anywhere in your app:
import { missionPhysics } from '$lib/sims/firefly/physics-bridge.svelte';
// In a component
const altitude = missionPhysics.telemetry?.altitude;
// In a utility function
const isLanded = missionPhysics.state?.phase === 'landed';
// In a landing page (yes, really)
<p>Current altitude: {missionPhysics.telemetry?.altitude}m</p> The physics runs in the background. You just tap into it.
Why this matters:
When I’m building a Kit for a customer, I can drop instruments anywhere - in the sim, in the sales page, in the docs. The telemetry is always live, always synced, always the same source of truth.
No prop drilling. No context wrappers. Just import and use.
Part 2: The Directory Structure (Why It’s Not Random)
Every Veenie sim follows this pattern:
/firefly-lander/
├── physics/ # The math (pure TypeScript, zero UI)
│ ├── engine.ts # RK4 integrator, state management
│ ├── forces/ # Thrust, gravity, drag (pure functions)
│ ├── systems/ # Propulsion, life support, etc
│ └── utils/ # Vec3, quaternions, coordinate transforms
│
├── pilot/ # The brain (command → action)
│ ├── commander.ts # High-level commands (SET_ALTITUDE, IGNITE)
│ ├── autopilot.ts # PID controllers, stability logic
│ └── commands.ts # Command types (for LLM API later)
│
├── context/ # The bridge (physics → UI)
│ ├── physics-bridge.svelte.ts # Singleton, telemetry, controls
│ ├── time-service.svelte.ts # Time scaling, pause/play
│ └── layout-context.svelte.ts # UI choreography (new!)
│
├── world/ # The 3D scene (Threlte components)
│ ├── Scene.svelte # Lights, camera, helpers
│ ├── player/ # Lander mesh, thrusters, trail
│ ├── space/ # Earth, Moon, Sun, stars
│ └── helpers/ # Debug grid, coordinate axes
│
└── ui/ # The interface (instruments, controls)
├── SimulationInterface.svelte # Master UI container
├── Zone.svelte # Zone-based layout (new!)
├── instruments/ # Altimeter, compass, etc
├── controls/ # WASD, Navball, SimView
└── time/ # Play/pause, calendar, timer Why This Structure?
Physics is portable. The /physics folder has zero browser dependencies. You can run it:
- In Node (for monte carlo testing)
- In Rust (via wasm-bindgen)
- On an Arduino (if you’re insane)
Pilot is testable. The /pilot folder speaks in commands, not key presses. An LLM can send the same commands as WASD keys. Same interface, different input.
World is swappable. Don’t like Threlte? Fine. The /world folder is the only place that touches Three.js. Swap it for Babylon.js, keep the physics.
UI is composable. Every instrument is a dumb component that reads from missionPhysics. No props. No events. Just reactive state.
Part 3: The Scrollytelling Engine (The Actual Innovation)
Here’s the killer feature: The sim responds to page scroll without tanking performance.
The Old Way (Every Other Scrollytelling Site):
<script>
let scrollY = $state(0);
</script>
<svelte:window bind:scrollY />
{#if scrollY > 1000}
<div>Show this thing</div>
{/if} Problem: This fires on every pixel scroll. At 60fps scrolling, that’s recalculating component visibility 60 times per second. RIP your battery.
The Veenie Way: Intersection Observer + Zone Choreography
Step 1: The Custom Scrolly Action
// utils.ts
export function scrolly(node: HTMLElement, callback: (step: string) => void) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const step = node.dataset.simStep;
if (step) callback(step);
}
});
},
{
rootMargin: '-40% 0% -40% 0%', // Trigger in center 20% of screen
threshold: 0
}
);
observer.observe(node);
return { destroy() { observer.disconnect(); } };
} This only fires when an element enters the sweet spot (center 20% of viewport). Not on every pixel. Just when sections change.
Step 2: The Story Manifest
// story.ts
export const MISSION_MANIFEST: Record<string, MissionBeat> = {
'hero': {
id: 'hero',
layout: 'hero', // Sim in top-right corner
title: 'Space Missions Made Fun to Fly',
instruments: {
status: true, // Show altitude/velocity
dynamics: true, // Show attitude indicator
propulsion: true, // Show thrust gauge
wasd: false // Hide controls (let them discover)
}
},
'deep-dive': {
id: 'deep-dive',
layout: 'split', // Sim takes left 50% of screen
title: 'Real Physics Engine',
instruments: {
status: true,
dynamics: true,
propulsion: true,
wasd: true // NOW show WASD controls
}
},
// ... more beats
}; Each “beat” defines:
- Layout preset (where the sim window goes)
- Instrument visibility (which gauges show)
- Camera position (optional)
- Narrative metadata (title, description)
Step 3: The Page Hooks It Up
<!-- KitPage.svelte -->
<script>
import { scrolly } from '$lib/utils';
import { missionLayout } from '$lib/sims/firefly/context/layout-context.svelte';
import { MISSION_MANIFEST } from './story';
function handleBeat(stepId: string) {
const beat = MISSION_MANIFEST[stepId];
if (beat) {
missionLayout.setPreset(beat.layout, beat);
}
}
</script>
<section use:scrolly={handleBeat} data-sim-step="hero">
<h1>Space Missions Made Fun to Fly</h1>
<p>Your mission as an interactive webpage...</p>
</section>
<section use:scrolly={handleBeat} data-sim-step="deep-dive">
<h2>Real Physics Engine</h2>
<p>RK4 integration, quaternion rotations...</p>
</section> When you scroll past each section, the use:scrolly action fires once, calls handleBeat(), which updates the layout singleton.
The sim watches the layout singleton and animates to the new position.
No prop drilling. No event bubbling. No per-frame recalcs.
Part 4: The Layout Choreography (Zone-Based UI)
Here’s where it gets compositional.
The Problem:
Different story beats need different UI layouts:
- Hero: Sim in corner, minimal UI
- Split: Sim on left, full telemetry on right
- Full: Sim takes whole screen, UI in viewport corners
How do you orchestrate 10+ instruments across layout changes without manually positioning each one?
The Solution: Zone-Based Composition
Step 1: Define Zones
// layout-context.svelte.ts
const PRESETS = {
hero: { x: 45, y: 35, w: 52, h: 62, radius: '2rem' },
split: { x: 50, y: 0, w: 50, h: 100, radius: '0' },
full: { x: 0, y: 0, w: 100, h: 100, radius: '0' }
};
type Zone =
| 'top-left'
| 'top-right'
| 'bottom-left'
| 'bottom-right'
| 'bottom-center'; Step 2: Assign Instruments to Zones
ui = $state<Record<string, ComponentState>>({
status: {
zone: 'top-left',
anchor: 'container', // Relative to sim window
visible: true
},
dynamics: {
zone: 'top-right',
anchor: 'container',
visible: true
},
wasd: {
zone: 'bottom-left',
anchor: 'viewport', // Fixed to screen
visible: false
}
}); Step 3: Render Zones
<!-- SimulationInterface.svelte -->
<script>
import Zone from './Zone.svelte';
const zones = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
</script>
<div class="absolute inset-0 pointer-events-none">
{#each zones as zoneId}
<Zone {zoneId} />
{/each}
</div> Step 4: Zone Component Dynamically Renders Its Children
<!-- Zone.svelte -->
<script>
import { missionLayout } from '../context/layout-context.svelte';
import { flip } from 'svelte/animate';
import { fade } from 'svelte/transition';
const COMPONENTS = {
status: StatusInfo,
dynamics: FlightDynamics,
propulsion: PropulsionInfo,
wasd: WASD
};
let { zoneId } = $props();
const activeItems = $derived(
Object.entries(missionLayout.ui)
.filter(([id, state]) =>
state.visible &&
state.zone === zoneId &&
COMPONENTS[id]
)
.map(([id]) => ({ id, Component: COMPONENTS[id] }))
);
</script>
<div style={missionLayout.getZoneStyle(zoneId)}>
{#each activeItems as { id, Component } (id)}
<div animate:flip={{ duration: 600 }} transition:fade>
<Component />
</div>
{/each}
</div> Now when you change layouts, instruments automatically flow to their new zones with smooth animations.
The “wasd” control moves from sim-relative (hero) to viewport-fixed (split) without a single line of positioning code.
Part 5: Manual Override (The SimView Control)
But what if the user wants to explore? They shouldn’t be locked into your scrollytelling narrative.
Enter SimView - a floating control that lets users:
- Pick any layout preset (hero, split, full, etc)
- Toggle scrollytelling on/off
- See a minimap of where the sim window is
<!-- SimView.svelte -->
<script>
function toggleScrollytelling() {
missionLayout.toggleScrollytelling();
}
</script>
<button onclick={toggleScrollytelling}>
{#if scrollytellingActive}
<Play /> Scrollytelling Active
{:else}
<Pause /> Manual Control
{/if}
</button>
<div class="preset-grid">
{#each presets as preset}
<button onclick={() => missionLayout.setPreset(preset.id, undefined, true)}>
<div class="minimap" style={renderMini(preset.rect)}></div>
</button>
{/each}
</div> When scrollytelling is disabled, scroll events are ignored. The user can click presets manually.
When scrollytelling is enabled, scroll events resume control.
The force: true flag in setPreset() bypasses the scrollytelling check, so manual clicks always work.
Part 6: The Physics Bridge (How UI Reads Telemetry)
Every instrument component does this:
<!-- Altimeter.svelte -->
<script>
import { missionPhysics } from '../../context/physics-bridge.svelte';
const altitude = $derived(missionPhysics.telemetry?.altitude ?? 0);
</script>
<div class="altimeter">
<div class="needle" style="rotate: {altitude * 0.36}deg"></div>
<div class="readout">{altitude.toFixed(0)} m</div>
</div> No props. No events. Just reactive derivation from the singleton.
The physics engine updates at 60fps. Svelte’s reactivity propagates changes to all instruments automatically.
But here’s the magic: The physics engine is headless. It doesn’t know about the UI. It just exposes:
class FireflyLanderPhysicsBridge {
getRenderTelemetry(): RenderTelemetry {
const raw = this.engine.getTelemetry();
return {
altitude: raw.altitude,
velocity: inertialToThreeJs(raw.velocity), // Coordinate transform
attitude: quatInertialToThreeJs(raw.attitude),
// ... more telemetry
};
}
} The bridge converts physics coordinates (inertial frame, Z-up) to render coordinates (Three.js frame, Y-up).
UI components consume the converted telemetry. Physics never touches rendering.
Part 7: Why This Architecture Scales
For Solo Devs (Me):
The transition from ‘Client Spec’ to ‘Live Telemetry’ is now a 5 Julian Day sprint. Here’s what’s cracking:
- Copy
/firefly-landerdirectory - Swap out
/physics/engine.tswith new equations - Update
/world/player/with new 3D model - Adjust instrument readings
- Done
The layout system, zone choreography, and singleton pattern stay the same.
For Customers (Space Companies):
They get composable pieces:
Want just the instruments in their existing site?
Import the singleton, drop<Altimeter />anywhere.Want just the 3D scene in Unity?
The physics engine is pure TypeScript. Port to C# or run via JS interop.Want the full sim but with their branding?
Fork the repo, swap assets, done.
For AI Training (Future):
The /pilot/commander.ts command interface is ready for LLMs:
// LLM sends JSON
{ "type": "SET_TARGET_ALTITUDE", "altitude": 10000 }
// Pilot executes
pilot.dispatch(command);
// Physics updates
missionPhysics.step(dt);
// LLM gets telemetry back
pilot.getTelemetry(); Same code. Different input source.
The File Tree (Annotated)
Let me walk you through what’s where:
/firefly-lander/
├── FireflyLanderSim.svelte # Entry point (20 lines)
│ - Wraps Canvas + SimulationInterface
│ - Listens for activePreset prop
│ - That's it
│
├── context/
│ ├── physics-bridge.svelte.ts # Singleton, telemetry conversion
│ ├── time-service.svelte.ts # Time scaling, pause/play
│ └── layout-context.svelte.ts # Zone choreography, presets
│
├── physics/ # Zero UI dependencies
│ ├── engine.ts # Main sim loop (RK4)
│ ├── forces/ # Thrust, gravity, drag
│ ├── systems/ # Propulsion, life support
│ └── utils/ # Math primitives
│
├── pilot/ # Command interface
│ ├── commander.ts # High-level → low-level
│ └── commands.ts # Type definitions
│
├── world/ # Threlte/Three.js only
│ ├── Scene.svelte # Lights, camera, fog
│ ├── player/ # Lander mesh, thrusters
│ └── space/ # Celestial bodies
│
└── ui/
├── SimulationInterface.svelte # Zone renderer
├── Zone.svelte # Dynamic component loader
├── instruments/ # Dumb components (read singleton)
├── controls/ # WASD, Navball, SimView
└── time/ # Play/pause, calendar Every directory has a clear responsibility. No circular dependencies.
What’s Next (The Roadmap)
Short Term (Q1 2026):
- Port Venus balloon sim to new singleton pattern
- Add camera choreography to story beats
- Build instrument editor (drag-drop zones)
Medium Term (Q2-Q3 2026):
- LLM pilot API - Let your favorite AI fly missions
- WebGPU physics - Offload math to GPU compute shaders
- Rust/WASM - Write crazy performant physics
- Hardware telemetry bridge - Real test stand → Live sim
- Multi-vehicle support - Formation flying, docking
Long Term (2027+):
- VR mode - Same sim, different renderer
- Monte Carlo runner - 10K sim runs in parallel
- Actual Venus Mission - 10K sim runs in parallel
The architecture supports all of this without major refactors.
Try It Yourself
The Firefly lander demo is live right now, scrollytelly choreography mode enabled.
Dev Mode Easter Eggs:
Open DevTools console and type:
// Access the physics singleton
missionPhysics.telemetry
// Manually trigger a layout change
missionLayout.setPreset('full')
// Toggle scrollytelling
missionLayout.toggleScrollytelling()
// Get debug info
missionPhysics.getDebugInfo() The entire sim is exposed because transparency builds trust.
For Customers Reading This
When you buy a Veenie Kit, you’re not getting a black box.
You’re getting:
- The full source code
- The composable architecture
- The deployment configs
- A 1-hour walkthrough
Because the whole point is you can own and modify this after I hand it off.
Death-by-PDF is dead. Long live interactive physics.
Questions? Email me: ivan@veenie.space
Want to build something like this? Book a Kit or just fork the architecture and make something weird.
Ivan Karaman builds physics simulations for space companies and occasionally remembers he was supposed to be sleeping.
Related: