diff --git a/src/components/voyage/SpaceVoyage.tsx b/src/components/voyage/SpaceVoyage.tsx
new file mode 100644
index 00000000..cf91e80a
--- /dev/null
+++ b/src/components/voyage/SpaceVoyage.tsx
@@ -0,0 +1,63 @@
+// Main Space Voyage component for /voyage.
+// A flying star-galleon explores TanStack libraries scattered across three
+// altitude "dimensions" — a spacefaring cousin of the Island Explorer.
+
+import { useState } from 'react'
+import { VoyageScene } from './VoyageScene'
+import { VoyageHUD } from './ui/VoyageHUD'
+import type { VoyageEngine } from './engine/VoyageEngine'
+
+const LOADING_MESSAGES = [
+ ['Hoisting the solar sails...', 'Mind the cosmic wind'],
+ ['Charting the star lanes...', 'X marks the nebula'],
+ ['Waking the stardust crew...', 'They sleep in zero-g'],
+ ['Tuning the dimension drive...', 'High, low, and in between'],
+ ['Polishing the brass telescope...', 'For spotting distant worlds'],
+ ['Counting the constellations...', 'We lost count at infinity'],
+ ['Feeding the ship cat...', 'Even pirates need a navigator'],
+ ['Calibrating the gravity anchor...', 'Down is relative out here'],
+]
+
+function LoadingOverlay() {
+ const [messageIndex] = useState(() =>
+ Math.floor(Math.random() * LOADING_MESSAGES.length),
+ )
+ const [headline, subtext] = LOADING_MESSAGES[messageIndex]
+
+ return (
+
+
+
+
{headline}
+
{subtext}
+
+
+ )
+}
+
+export default function SpaceVoyage() {
+ const [isLoading, setIsLoading] = useState(true)
+ const [engine, setEngine] = useState(null)
+
+ return (
+
+ {isLoading &&
}
+
+ {/* 3D scene */}
+
+
+
+
+ {/* Vignette for depth */}
+
+
+ {!isLoading &&
}
+
+ )
+}
diff --git a/src/components/voyage/VoyageScene.tsx b/src/components/voyage/VoyageScene.tsx
new file mode 100644
index 00000000..1e58e87b
--- /dev/null
+++ b/src/components/voyage/VoyageScene.tsx
@@ -0,0 +1,92 @@
+import { useEffect, useRef, useState } from 'react'
+import { VoyageEngine } from './engine/VoyageEngine'
+
+interface VoyageSceneProps {
+ onLoadingChange?: (loading: boolean) => void
+ onEngineReady?: (engine: VoyageEngine | null) => void
+}
+
+export function VoyageScene({
+ onLoadingChange,
+ onEngineReady,
+}: VoyageSceneProps) {
+ const containerRef = useRef(null)
+ const canvasRef = useRef(null)
+ const engineRef = useRef(null)
+ const [isReady, setIsReady] = useState(false)
+
+ // Wait until the container has real dimensions before booting the engine.
+ useEffect(() => {
+ const container = containerRef.current
+ if (!container) return
+
+ const checkSize = () => {
+ if (container.clientWidth > 0 && container.clientHeight > 0) {
+ setIsReady(true)
+ }
+ }
+
+ checkSize()
+ requestAnimationFrame(checkSize)
+
+ const observer = new ResizeObserver(checkSize)
+ observer.observe(container)
+ return () => observer.disconnect()
+ }, [])
+
+ useEffect(() => {
+ if (!isReady) return
+ const canvas = canvasRef.current
+ const container = containerRef.current
+ if (!canvas || !container) return
+
+ const width = container.clientWidth
+ const height = container.clientHeight
+ const dpr = Math.min(window.devicePixelRatio, 2)
+ canvas.width = width * dpr
+ canvas.height = height * dpr
+ canvas.style.width = `${width}px`
+ canvas.style.height = `${height}px`
+
+ const engine = new VoyageEngine(canvas)
+ engineRef.current = engine
+
+ const resizeObserver = new ResizeObserver((entries) => {
+ const entry = entries[0]
+ if (entry && engineRef.current) {
+ const { width, height } = entry.contentRect
+ engineRef.current.resize(width, height)
+ }
+ })
+ resizeObserver.observe(container)
+
+ onLoadingChange?.(true)
+ engine
+ .init()
+ .then(() => {
+ engine.start()
+ onLoadingChange?.(false)
+ onEngineReady?.(engine)
+ })
+ .catch((err) => {
+ console.error('VoyageEngine init failed:', err)
+ onLoadingChange?.(false)
+ })
+
+ return () => {
+ resizeObserver.disconnect()
+ onEngineReady?.(null)
+ engine.dispose()
+ engineRef.current = null
+ }
+ }, [isReady, onLoadingChange, onEngineReady])
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/voyage/engine/VoyageEngine.ts b/src/components/voyage/engine/VoyageEngine.ts
new file mode 100644
index 00000000..c998f2bd
--- /dev/null
+++ b/src/components/voyage/engine/VoyageEngine.ts
@@ -0,0 +1,1735 @@
+import * as THREE from 'three'
+import { modelLoader } from '../../game/engine/loaders/ModelLoader'
+import { BANDS, PLANETS, type Planet } from '../planets'
+import { PLAYER_MAX_HEALTH, useVoyageStore } from '../store'
+
+// ---------------------------------------------------------------------------
+// Tuning constants
+// ---------------------------------------------------------------------------
+const WORLD_RADIUS = 150
+const THRUST_ACCEL = 30
+const MAX_SPEED = 38
+const REVERSE_SPEED = 16
+const TURN_SPEED = 1.9
+const STAR_COUNT = 2200
+const TRAIL_MAX = 600
+const VISIT_RANGE = 13 // extra distance (beyond planet radius) to "visit"
+
+// Combat
+const ENEMY_COUNT = 7
+const ENEMY_RADIUS = 3.6 // collision radius for cannonballs vs pirate
+const ENEMY_MAX_HEALTH = 3 // cannonball hits to sink
+const ENEMY_SPEED = 14
+const ENEMY_AGGRO = 46 // pursue only when you come near (kept below the
+// spawn standoff so open space — and the spawn point — is safe to cruise)
+const ENEMY_FIRE_RANGE = 42
+const ENEMY_FIRE_INTERVAL = 2.8 // seconds between enemy shots
+const ENEMY_RESPAWN_DELAY = 8 // seconds before a sunk pirate returns
+const ENEMY_BAND_TOLERANCE = 26 // must be within this Y gap to fight
+const PLAYER_RADIUS = 3.0
+const PLAYER_FIRE_INTERVAL = 0.3 // seconds between player shots
+const PLAYER_BALL_SPEED = 95
+const PLAYER_BALL_LIFE = 1.5
+const PLAYER_BALL_DAMAGE = 1
+const ENEMY_BALL_SPEED = 58
+const ENEMY_BALL_LIFE = 2.4
+const ENEMY_BALL_DAMAGE = 8
+const COLLISION_DAMAGE = 16 // ramming a pirate
+const HULL_REGEN = 6 // hull points/sec recovered when not under fire
+const REGEN_DELAY = 3 // seconds after a hit before regen kicks in
+const RESPAWN_DELAY = 2.8 // player shipwreck → respawn
+const INVULN_TIME = 2.6 // post-respawn grace
+const BURST_MAX = 700
+
+interface TrailParticle {
+ x: number
+ y: number
+ z: number
+ birth: number
+ life: number
+ size: number
+ color: THREE.Color
+}
+
+interface BurstParticle {
+ pos: THREE.Vector3
+ vel: THREE.Vector3
+ birth: number
+ life: number
+ size: number
+ color: THREE.Color
+}
+
+interface Projectile {
+ pos: THREE.Vector3
+ vel: THREE.Vector3
+ life: number
+ fromPlayer: boolean
+ damage: number
+ mesh: THREE.Mesh
+}
+
+interface Enemy {
+ group: THREE.Group
+ pos: THREE.Vector3
+ yaw: number
+ roll: number
+ bandY: number
+ health: number
+ alive: boolean
+ fireTimer: number
+ respawnTimer: number
+ wanderSeed: number
+}
+
+// End-game boss gauntlet — escalating "boss men" unlocked after charting
+// every world. Each tier is bigger, tougher, and fires harder.
+interface BossLevel {
+ name: string
+ color: string
+ health: number
+ scale: number
+ speed: number
+ fireInterval: number
+ ballDamage: number
+ shots: number
+ escorts: number
+ reward: number
+}
+
+const BOSS_LEVELS: BossLevel[] = [
+ {
+ name: 'Bronze Marauder',
+ color: '#cd7f32',
+ health: 9,
+ scale: 1.0,
+ speed: 17,
+ fireInterval: 1.7,
+ ballDamage: 9,
+ shots: 1,
+ escorts: 0,
+ reward: 1000,
+ },
+ {
+ name: 'Silver Corsair',
+ color: '#cfd6de',
+ health: 15,
+ scale: 1.18,
+ speed: 20,
+ fireInterval: 1.35,
+ ballDamage: 11,
+ shots: 2,
+ escorts: 1,
+ reward: 2500,
+ },
+ {
+ name: 'Gold Dread Admiral',
+ color: '#ffd24a',
+ health: 24,
+ scale: 1.4,
+ speed: 23,
+ fireInterval: 1.05,
+ ballDamage: 13,
+ shots: 3,
+ escorts: 2,
+ reward: 5000,
+ },
+]
+
+interface Boss {
+ group: THREE.Group
+ pos: THREE.Vector3
+ yaw: number
+ roll: number
+ health: number
+ maxHealth: number
+ level: number
+ fireTimer: number
+ radius: number
+ config: BossLevel
+}
+
+interface PlanetView {
+ data: Planet
+ group: THREE.Group
+ core: THREE.Mesh
+ halo: THREE.Mesh
+ ring: THREE.Mesh
+ label: THREE.Sprite
+ coreMat: THREE.MeshStandardMaterial
+ haloMat: THREE.MeshBasicMaterial
+ baseColor: THREE.Color
+ discovered: boolean
+ hover: number // 0..1 eased hover highlight
+ pulse: number // 0..1 celebratory scale pop on discovery
+}
+
+export class VoyageEngine {
+ private canvas: HTMLCanvasElement
+ private renderer: THREE.WebGLRenderer
+ private scene: THREE.Scene
+ private camera: THREE.PerspectiveCamera
+ private clock: THREE.Clock
+ private frameId: number | null = null
+ private disposed = false
+ private paused = false
+
+ private raycaster = new THREE.Raycaster()
+ private pointer = new THREE.Vector2()
+
+ // Ship state
+ private shipGroup = new THREE.Group()
+ private shipModel: THREE.Group | null = null
+ private shipPos = new THREE.Vector3(0, BANDS[0].y, 0)
+ private yaw = 0
+ private velocity = 0
+ private roll = 0
+ private pitch = 0
+ private targetBand = 0
+
+ // Environment
+ private starField: THREE.Points | null = null
+ private nebula: THREE.Mesh | null = null
+ private fog: THREE.Fog
+ private ambient: THREE.AmbientLight
+ private shipLight: THREE.PointLight
+
+ // Planets
+ private planetViews: PlanetView[] = []
+ private planetsGroup = new THREE.Group()
+ private planetMeshes: THREE.Mesh[] = []
+ private hovered: PlanetView | null = null
+ private completedCelebrated = false
+
+ // Trail
+ private trail: TrailParticle[] = []
+ private trailGeo: THREE.BufferGeometry
+ private trailMat: THREE.PointsMaterial
+ private trailPoints: THREE.Points
+
+ // Combat
+ private enemies: Enemy[] = []
+ private enemyGroup = new THREE.Group()
+ private projectiles: Projectile[] = []
+ private projectileGroup = new THREE.Group()
+ private ballPool: { player: THREE.Mesh[]; enemy: THREE.Mesh[] } = {
+ player: [],
+ enemy: [],
+ }
+ private ballGeo = new THREE.SphereGeometry(0.55, 10, 10)
+ private playerBallMat: THREE.MeshBasicMaterial
+ private enemyBallMat: THREE.MeshBasicMaterial
+ private fireTimer = 0
+ private playerHealth = PLAYER_MAX_HEALTH
+ private invulnUntil = 0
+ private dead = false
+ private respawnTimer = 0
+ private flashTimer = 0 // brief hull flash when hit
+ private lastDamageTime = -999 // for hull regen gating
+
+ // Boss gauntlet
+ private boss: Boss | null = null
+ private gauntletActive = false
+ private gauntletEnded = false
+ private bossIndex = 0
+ private nextBossTimer = 0
+
+ // Explosions / sparks
+ private bursts: BurstParticle[] = []
+ private burstGeo: THREE.BufferGeometry
+ private burstMat: THREE.PointsMaterial
+ private burstPoints: THREE.Points
+
+ // Input
+ private keys = new Set()
+ private boundKeyDown: (e: KeyboardEvent) => void
+ private boundKeyUp: (e: KeyboardEvent) => void
+
+ // Scratch
+ private camCurrent = new THREE.Vector3(0, BANDS[0].y + 12, -28)
+ private tmpForward = new THREE.Vector3()
+ private tmpTarget = new THREE.Vector3()
+ private fogBase = new THREE.Color('#05070f')
+ private fogTinted = new THREE.Color()
+
+ constructor(canvas: HTMLCanvasElement) {
+ this.canvas = canvas
+ this.clock = new THREE.Clock()
+
+ const w = canvas.clientWidth || window.innerWidth
+ const h = canvas.clientHeight || window.innerHeight
+
+ this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
+ this.renderer.setSize(w, h, false)
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping
+ this.renderer.toneMappingExposure = 1.1
+
+ this.scene = new THREE.Scene()
+ this.scene.background = new THREE.Color('#04060e')
+ this.fog = new THREE.Fog('#05070f', 120, 460)
+ this.scene.fog = this.fog
+
+ this.camera = new THREE.PerspectiveCamera(58, w / h || 1, 0.1, 2000)
+ this.camera.position.copy(this.camCurrent)
+
+ // Lighting — soft ambient so dark side of planets isn't pure black,
+ // plus a travelling light attached to the ship.
+ this.ambient = new THREE.AmbientLight('#8899ff', 0.55)
+ this.scene.add(this.ambient)
+
+ const key = new THREE.DirectionalLight('#fff3e0', 0.6)
+ key.position.set(60, 120, 40)
+ this.scene.add(key)
+
+ this.shipLight = new THREE.PointLight('#bfe9ff', 2.2, 80, 2)
+ this.scene.add(this.shipLight)
+
+ // Trail particle system
+ const trailPositions = new Float32Array(TRAIL_MAX * 3)
+ const trailColors = new Float32Array(TRAIL_MAX * 3)
+ const trailSizes = new Float32Array(TRAIL_MAX)
+ this.trailGeo = new THREE.BufferGeometry()
+ this.trailGeo.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(trailPositions, 3),
+ )
+ this.trailGeo.setAttribute(
+ 'color',
+ new THREE.BufferAttribute(trailColors, 3),
+ )
+ this.trailGeo.setAttribute('size', new THREE.BufferAttribute(trailSizes, 1))
+ this.trailMat = new THREE.PointsMaterial({
+ size: 1.4,
+ transparent: true,
+ opacity: 0.9,
+ vertexColors: true,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ map: makeSoftCircleTexture(),
+ })
+ this.trailPoints = new THREE.Points(this.trailGeo, this.trailMat)
+ this.trailPoints.frustumCulled = false
+ this.scene.add(this.trailPoints)
+
+ // Cannonball materials (shared) + projectile container.
+ this.playerBallMat = new THREE.MeshBasicMaterial({ color: '#ffd27a' })
+ this.enemyBallMat = new THREE.MeshBasicMaterial({ color: '#ff5a4d' })
+ this.scene.add(this.projectileGroup)
+ this.scene.add(this.enemyGroup)
+
+ // Explosion / spark particle system.
+ const burstPositions = new Float32Array(BURST_MAX * 3)
+ const burstColors = new Float32Array(BURST_MAX * 3)
+ const burstSizes = new Float32Array(BURST_MAX)
+ this.burstGeo = new THREE.BufferGeometry()
+ this.burstGeo.setAttribute(
+ 'position',
+ new THREE.BufferAttribute(burstPositions, 3),
+ )
+ this.burstGeo.setAttribute(
+ 'color',
+ new THREE.BufferAttribute(burstColors, 3),
+ )
+ this.burstGeo.setAttribute('size', new THREE.BufferAttribute(burstSizes, 1))
+ this.burstMat = new THREE.PointsMaterial({
+ size: 2.4,
+ transparent: true,
+ opacity: 1,
+ vertexColors: true,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ map: makeSoftCircleTexture(),
+ })
+ this.burstPoints = new THREE.Points(this.burstGeo, this.burstMat)
+ this.burstPoints.frustumCulled = false
+ this.scene.add(this.burstPoints)
+
+ this.boundKeyDown = this.handleKeyDown.bind(this)
+ this.boundKeyUp = this.handleKeyUp.bind(this)
+ }
+
+ async init(): Promise {
+ try {
+ await modelLoader.load('/models/ship.glb')
+ } catch (err) {
+ console.error('[VoyageEngine] Failed to load ship model:', err)
+ }
+
+ this.buildStarfield()
+ this.buildNebula()
+ this.buildPlanets()
+ this.buildShip()
+ this.buildEnemies()
+
+ this.scene.add(this.planetsGroup)
+ this.scene.add(this.shipGroup)
+
+ window.addEventListener('keydown', this.boundKeyDown)
+ window.addEventListener('keyup', this.boundKeyUp)
+ document.addEventListener('visibilitychange', this.handleVisibility)
+ this.canvas.addEventListener('click', this.handleClick)
+ this.canvas.addEventListener('pointermove', this.handlePointerMove)
+
+ // Seed HUD state
+ useVoyageStore.getState().setBand(0, 0)
+ }
+
+ // -------------------------------------------------------------------------
+ // World construction
+ // -------------------------------------------------------------------------
+ private buildStarfield(): void {
+ const positions = new Float32Array(STAR_COUNT * 3)
+ const colors = new Float32Array(STAR_COUNT * 3)
+ const palette = [
+ new THREE.Color('#ffffff'),
+ new THREE.Color('#bcd4ff'),
+ new THREE.Color('#ffe6c2'),
+ new THREE.Color('#d9c2ff'),
+ ]
+ for (let i = 0; i < STAR_COUNT; i++) {
+ // Distribute in a big shell around the play space.
+ const r = 280 + Math.pow(seeded(i * 1.7), 0.5) * 520
+ const theta = seeded(i * 2.3) * Math.PI * 2
+ const phi = Math.acos(2 * seeded(i * 3.1) - 1)
+ positions[i * 3] = r * Math.sin(phi) * Math.cos(theta)
+ positions[i * 3 + 1] = r * Math.cos(phi) * 0.6 + 80
+ positions[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta)
+ const c = palette[Math.floor(seeded(i * 5.9) * palette.length)]
+ colors[i * 3] = c.r
+ colors[i * 3 + 1] = c.g
+ colors[i * 3 + 2] = c.b
+ }
+ const geo = new THREE.BufferGeometry()
+ geo.setAttribute('position', new THREE.BufferAttribute(positions, 3))
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+ const mat = new THREE.PointsMaterial({
+ size: 2.2,
+ sizeAttenuation: true,
+ transparent: true,
+ vertexColors: true,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ map: makeSoftCircleTexture(),
+ })
+ this.starField = new THREE.Points(geo, mat)
+ this.starField.frustumCulled = false
+ this.scene.add(this.starField)
+ }
+
+ private buildNebula(): void {
+ // Large inward-facing sphere with a vertical color gradient baked into
+ // vertex colors — cheap "deep space" backdrop.
+ const geo = new THREE.SphereGeometry(900, 32, 32)
+ const top = new THREE.Color('#1a0b3a')
+ const mid = new THREE.Color('#0a1240')
+ const bottom = new THREE.Color('#02030a')
+ const pos = geo.attributes.position as THREE.BufferAttribute
+ const colors = new Float32Array(pos.count * 3)
+ const c = new THREE.Color()
+ for (let i = 0; i < pos.count; i++) {
+ const t = (pos.getY(i) / 900 + 1) / 2 // 0 bottom .. 1 top
+ if (t > 0.5) {
+ c.copy(mid).lerp(top, (t - 0.5) * 2)
+ } else {
+ c.copy(bottom).lerp(mid, t * 2)
+ }
+ colors[i * 3] = c.r
+ colors[i * 3 + 1] = c.g
+ colors[i * 3 + 2] = c.b
+ }
+ geo.setAttribute('color', new THREE.BufferAttribute(colors, 3))
+ const mat = new THREE.MeshBasicMaterial({
+ vertexColors: true,
+ side: THREE.BackSide,
+ fog: false,
+ depthWrite: false,
+ })
+ this.nebula = new THREE.Mesh(geo, mat)
+ this.scene.add(this.nebula)
+ }
+
+ private buildPlanets(): void {
+ for (const data of PLANETS) {
+ const group = new THREE.Group()
+ group.position.set(...data.position)
+
+ const baseColor = new THREE.Color(data.color)
+
+ // Core sphere — starts dim/uncharted, lights up on discovery.
+ const coreMat = new THREE.MeshStandardMaterial({
+ color: new THREE.Color('#2b3344'),
+ emissive: new THREE.Color('#0c1018'),
+ emissiveIntensity: 0.4,
+ roughness: 0.65,
+ metalness: 0.1,
+ })
+ const core = new THREE.Mesh(
+ new THREE.IcosahedronGeometry(data.radius, 3),
+ coreMat,
+ )
+ core.userData.planetId = data.id
+ group.add(core)
+ this.planetMeshes.push(core)
+
+ // Atmosphere halo (additive backside shell).
+ const haloMat = new THREE.MeshBasicMaterial({
+ color: baseColor.clone(),
+ transparent: true,
+ opacity: 0.0,
+ side: THREE.BackSide,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ })
+ const halo = new THREE.Mesh(
+ new THREE.SphereGeometry(data.radius * 1.35, 24, 24),
+ haloMat,
+ )
+ group.add(halo)
+
+ // Tilted ring.
+ const ring = new THREE.Mesh(
+ new THREE.TorusGeometry(data.radius * 1.9, data.radius * 0.09, 12, 64),
+ new THREE.MeshBasicMaterial({
+ color: baseColor.clone(),
+ transparent: true,
+ opacity: 0.0,
+ blending: THREE.AdditiveBlending,
+ depthWrite: false,
+ }),
+ )
+ ring.rotation.x = Math.PI / 2.4
+ ring.rotation.y = data.position[0] * 0.05
+ group.add(ring)
+
+ // Floating label.
+ const label = makeLabelSprite(data.shortName, data.color)
+ label.position.set(0, data.radius + 5, 0)
+ label.visible = false
+ group.add(label)
+
+ this.planetsGroup.add(group)
+ this.planetViews.push({
+ data,
+ group,
+ core,
+ halo,
+ ring,
+ label,
+ coreMat,
+ haloMat,
+ baseColor,
+ discovered: false,
+ hover: 0,
+ pulse: 0,
+ })
+ }
+ }
+
+ private buildShip(): void {
+ if (modelLoader.isLoaded('/models/ship.glb')) {
+ const tint = new THREE.Color('#ffe8cc')
+ this.shipModel = modelLoader.clone('/models/ship.glb', tint)
+ this.shipModel.scale.setScalar(0.5)
+ this.shipGroup.add(this.shipModel)
+ } else {
+ // Fallback primitive so the page still works if the model fails.
+ const hull = new THREE.Mesh(
+ new THREE.ConeGeometry(0.8, 3, 8),
+ new THREE.MeshStandardMaterial({ color: '#c8a878' }),
+ )
+ hull.rotation.x = Math.PI / 2
+ this.shipGroup.add(hull)
+ }
+ this.shipGroup.position.copy(this.shipPos)
+ }
+
+ private makeEnemyModel(): THREE.Group {
+ const group = new THREE.Group()
+ if (modelLoader.isLoaded('/models/ship.glb')) {
+ // Dark, blood-red tinted galleon — clearly a marauder.
+ const model = modelLoader.clone(
+ '/models/ship.glb',
+ new THREE.Color('#7a2230'),
+ )
+ model.scale.setScalar(0.46)
+ group.add(model)
+ } else {
+ const hull = new THREE.Mesh(
+ new THREE.ConeGeometry(0.8, 3, 8),
+ new THREE.MeshStandardMaterial({ color: '#7a2230' }),
+ )
+ hull.rotation.x = Math.PI / 2
+ group.add(hull)
+ }
+ // Menacing red running light so pirates are visible at distance.
+ const lamp = new THREE.PointLight('#ff5a4d', 1.4, 26, 2)
+ lamp.position.set(0, 2, 0)
+ group.add(lamp)
+ return group
+ }
+
+ private buildEnemies(): void {
+ for (let i = 0; i < ENEMY_COUNT; i++) {
+ const group = this.makeEnemyModel()
+ this.enemyGroup.add(group)
+ const enemy: Enemy = {
+ group,
+ pos: new THREE.Vector3(),
+ yaw: 0,
+ roll: 0,
+ bandY: 0,
+ health: ENEMY_MAX_HEALTH,
+ alive: true,
+ fireTimer: 1 + seeded(i * 9.3) * ENEMY_FIRE_INTERVAL,
+ respawnTimer: 0,
+ wanderSeed: seeded(i * 3.7) * 100,
+ }
+ this.spawnEnemy(enemy, i)
+ this.enemies.push(enemy)
+ }
+ }
+
+ /** Place a pirate near a random planet (so they guard the worlds). */
+ private spawnEnemy(enemy: Enemy, i: number): void {
+ // Re-roll a few times so pirates don't spawn on top of the player or
+ // right at the world centre (the player's spawn / respawn point).
+ let x = 0
+ let z = 0
+ let band = BANDS[0]
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const seed = enemy.wanderSeed + i * 1.13 + attempt * 0.37
+ const planet = PLANETS[Math.floor(seeded(seed * 2.1) * PLANETS.length)]
+ band = BANDS[planet.band]
+ const angle = seeded(seed * 4.7) * Math.PI * 2
+ const dist = 18 + seeded(seed * 6.3) * 26
+ x = planet.position[0] + Math.cos(angle) * dist
+ z = planet.position[2] + Math.sin(angle) * dist
+ const fromCenter = Math.hypot(x, z)
+ const fromPlayer = Math.hypot(x - this.shipPos.x, z - this.shipPos.z)
+ if (fromCenter > 50 && fromPlayer > 45) break
+ }
+ // Ensure a minimum standoff from the world centre regardless.
+ const r = Math.hypot(x, z)
+ if (r < 55) {
+ const a = Math.atan2(z, x)
+ x = Math.cos(a) * 60
+ z = Math.sin(a) * 60
+ }
+ enemy.bandY = band.y
+ enemy.pos.set(x, band.y, z)
+ enemy.yaw = seeded(enemy.wanderSeed * 8.9 + i) * Math.PI * 2
+ enemy.health = ENEMY_MAX_HEALTH
+ enemy.alive = true
+ enemy.group.visible = true
+ enemy.group.position.copy(enemy.pos)
+ }
+
+ // -------------------------------------------------------------------------
+ // Input
+ // -------------------------------------------------------------------------
+ private handleKeyDown(e: KeyboardEvent): void {
+ const code = e.code
+ if (
+ [
+ 'ArrowUp',
+ 'ArrowDown',
+ 'ArrowLeft',
+ 'ArrowRight',
+ 'Space',
+ 'KeyW',
+ 'KeyA',
+ 'KeyS',
+ 'KeyD',
+ 'KeyQ',
+ 'KeyE',
+ 'PageUp',
+ 'PageDown',
+ ].includes(code)
+ ) {
+ e.preventDefault()
+ }
+ if (e.repeat) return
+ this.keys.add(code)
+
+ // Discrete altitude-band changes (climb / dive). Space is reserved for
+ // firing the cannons (handled each frame while held).
+ if (code === 'KeyE' || code === 'PageUp') {
+ this.changeBand(1)
+ } else if (code === 'KeyQ' || code === 'PageDown') {
+ this.changeBand(-1)
+ }
+ }
+
+ private handleKeyUp(e: KeyboardEvent): void {
+ this.keys.delete(e.code)
+ }
+
+ /** Public hook so on-screen touch buttons can drive band changes. */
+ changeBand(dir: number): void {
+ this.targetBand = THREE.MathUtils.clamp(
+ this.targetBand + dir,
+ 0,
+ BANDS.length - 1,
+ )
+ }
+
+ /** Public hook so touch controls can set continuous input. */
+ setKey(code: string, pressed: boolean): void {
+ if (pressed) this.keys.add(code)
+ else this.keys.delete(code)
+ }
+
+ private handleVisibility = (): void => {
+ this.paused = document.hidden
+ if (!document.hidden) this.clock.getDelta()
+ }
+
+ private handleClick = (event: MouseEvent): void => {
+ const view = this.raycastPlanet(event.clientX, event.clientY)
+ if (view) {
+ window.location.assign(view.data.url)
+ }
+ }
+
+ private handlePointerMove = (event: PointerEvent): void => {
+ const view = this.raycastPlanet(event.clientX, event.clientY)
+ this.canvas.style.cursor = view ? 'pointer' : 'auto'
+ this.hovered = view
+ }
+
+ private raycastPlanet(clientX: number, clientY: number): PlanetView | null {
+ const rect = this.canvas.getBoundingClientRect()
+ this.pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1
+ this.pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1
+ this.raycaster.setFromCamera(this.pointer, this.camera)
+ const hits = this.raycaster.intersectObjects(this.planetMeshes, false)
+ if (hits.length === 0) return null
+ const id = hits[0].object.userData.planetId
+ return this.planetViews.find((v) => v.data.id === id) ?? null
+ }
+
+ // -------------------------------------------------------------------------
+ // Loop
+ // -------------------------------------------------------------------------
+ start(): void {
+ this.clock.start()
+ // Brief grace period so the player isn't ambushed before they get oriented.
+ this.invulnUntil = this.clock.getElapsedTime() + 3.5
+ this.loop()
+ }
+
+ stop(): void {
+ if (this.frameId !== null) {
+ cancelAnimationFrame(this.frameId)
+ this.frameId = null
+ }
+ }
+
+ private loop = (): void => {
+ if (this.disposed) return
+ this.frameId = requestAnimationFrame(this.loop)
+ if (this.paused) return
+ const delta = Math.min(this.clock.getDelta(), 0.05)
+ const time = this.clock.getElapsedTime()
+ this.update(delta, time)
+ this.renderer.render(this.scene, this.camera)
+ }
+
+ private update(delta: number, time: number): void {
+ this.updateShip(delta)
+ this.updateCamera(delta)
+ this.updateTrail(time)
+ this.updatePlanets(delta, time)
+ this.updateCombat(delta)
+ this.updateBursts(delta)
+ this.updateEnvironment(delta)
+ }
+
+ private updateShip(delta: number): void {
+ const k = this.keys
+ // No control input while shipwrecked (drifts to a stop, then respawns).
+ const ctl = !this.dead
+ const forward = ctl && (k.has('ArrowUp') || k.has('KeyW'))
+ const back = ctl && (k.has('ArrowDown') || k.has('KeyS'))
+ const left = ctl && (k.has('ArrowLeft') || k.has('KeyA'))
+ const right = ctl && (k.has('ArrowRight') || k.has('KeyD'))
+
+ // Yaw
+ let turnInput = 0
+ if (left) turnInput += 1
+ if (right) turnInput -= 1
+ this.yaw += turnInput * TURN_SPEED * delta
+
+ // Thrust
+ if (forward) {
+ this.velocity = Math.min(this.velocity + THRUST_ACCEL * delta, MAX_SPEED)
+ } else if (back) {
+ this.velocity = Math.max(
+ this.velocity - THRUST_ACCEL * delta,
+ -REVERSE_SPEED,
+ )
+ } else {
+ // Exponential drag toward zero.
+ this.velocity *= Math.exp(-1.4 * delta)
+ if (Math.abs(this.velocity) < 0.02) this.velocity = 0
+ }
+
+ // Translate along heading (yaw 0 => +Z, matching the boat convention).
+ this.shipPos.x += Math.sin(this.yaw) * this.velocity * delta
+ this.shipPos.z += Math.cos(this.yaw) * this.velocity * delta
+
+ // Keep inside a circular world boundary.
+ const radial = Math.hypot(this.shipPos.x, this.shipPos.z)
+ if (radial > WORLD_RADIUS) {
+ const s = WORLD_RADIUS / radial
+ this.shipPos.x *= s
+ this.shipPos.z *= s
+ this.velocity *= 0.9
+ }
+
+ // Smoothly settle to the target altitude band.
+ const targetY = BANDS[this.targetBand].y
+ const ease = 1 - Math.exp(-2.6 * delta)
+ const prevY = this.shipPos.y
+ this.shipPos.y += (targetY - this.shipPos.y) * ease
+ const climbRate = (this.shipPos.y - prevY) / Math.max(delta, 0.0001)
+
+ // Banking on turns + nose pitch on climb/dive + gentle idle bob.
+ const targetRoll = -turnInput * 0.35
+ this.roll += (targetRoll - this.roll) * (1 - Math.exp(-6 * delta))
+ const targetPitch = THREE.MathUtils.clamp(-climbRate * 0.012, -0.4, 0.4)
+ this.pitch += (targetPitch - this.pitch) * (1 - Math.exp(-5 * delta))
+
+ const bob = Math.sin(this.clock.getElapsedTime() * 1.4) * 0.25
+
+ this.shipGroup.position.set(
+ this.shipPos.x,
+ this.shipPos.y + bob,
+ this.shipPos.z,
+ )
+ this.shipGroup.rotation.set(this.pitch, this.yaw, this.roll)
+
+ this.shipLight.position.set(
+ this.shipPos.x,
+ this.shipPos.y + 3,
+ this.shipPos.z,
+ )
+
+ // Sync band index for the HUD (closest band + smooth altitude 0..n-1).
+ const span = BANDS[BANDS.length - 1].y - BANDS[0].y || 1
+ const altNorm = ((this.shipPos.y - BANDS[0].y) / span) * (BANDS.length - 1)
+ const nearestBand = Math.round(
+ THREE.MathUtils.clamp(altNorm, 0, BANDS.length - 1),
+ )
+ useVoyageStore
+ .getState()
+ .setBand(nearestBand, Math.round(altNorm * 100) / 100)
+ }
+
+ private updateCamera(delta: number): void {
+ // Heading-relative chase camera.
+ this.tmpForward.set(Math.sin(this.yaw), 0, Math.cos(this.yaw))
+ this.tmpTarget.copy(this.shipPos).addScaledVector(this.tmpForward, -26)
+ this.tmpTarget.y += 12
+
+ const ease = 1 - Math.exp(-4 * delta)
+ this.camCurrent.lerp(this.tmpTarget, ease)
+ this.camera.position.copy(this.camCurrent)
+
+ this.tmpTarget.copy(this.shipPos).addScaledVector(this.tmpForward, 10)
+ this.tmpTarget.y += 2
+ this.camera.lookAt(this.tmpTarget)
+ }
+
+ private updateTrail(time: number): void {
+ const now = time * 1000
+ // Spawn from behind the ship while moving.
+ if (Math.abs(this.velocity) > 2) {
+ const band = BANDS[this.targetBand]
+ const color = new THREE.Color(band.color)
+ const spawn = Math.min(3, Math.ceil(Math.abs(this.velocity) / 12))
+ for (let s = 0; s < spawn; s++) {
+ const back = 1.6 + Math.random() * 1.2
+ const spread = (Math.random() - 0.5) * 1.4
+ this.trail.push({
+ x:
+ this.shipPos.x -
+ Math.sin(this.yaw) * back +
+ Math.cos(this.yaw) * spread,
+ y: this.shipPos.y + (Math.random() - 0.5) * 0.8,
+ z:
+ this.shipPos.z -
+ Math.cos(this.yaw) * back -
+ Math.sin(this.yaw) * spread,
+ birth: now,
+ life: 900 + Math.random() * 700,
+ size: 0.8 + Math.random() * 1.4,
+ color,
+ })
+ }
+ }
+
+ this.trail = this.trail.filter((p) => now - p.birth < p.life)
+
+ const positions = this.trailGeo.attributes.position as THREE.BufferAttribute
+ const colors = this.trailGeo.attributes.color as THREE.BufferAttribute
+ const sizes = this.trailGeo.attributes.size as THREE.BufferAttribute
+ const count = Math.min(this.trail.length, TRAIL_MAX)
+ for (let i = 0; i < count; i++) {
+ const p = this.trail[i]
+ const age = (now - p.birth) / p.life
+ const fade = 1 - age
+ positions.setXYZ(i, p.x, p.y, p.z)
+ colors.setXYZ(i, p.color.r * fade, p.color.g * fade, p.color.b * fade)
+ sizes.setX(i, p.size * fade)
+ }
+ this.trailGeo.setDrawRange(0, count)
+ positions.needsUpdate = true
+ colors.needsUpdate = true
+ sizes.needsUpdate = true
+ }
+
+ private updatePlanets(delta: number, time: number): void {
+ const store = useVoyageStore.getState()
+ let nearest: PlanetView | null = null
+ let nearestDist = Infinity
+
+ for (const v of this.planetViews) {
+ // Idle spin + float.
+ v.core.rotation.y += delta * 0.25
+ v.ring.rotation.z += delta * 0.15
+ v.group.position.y =
+ v.data.position[1] + Math.sin(time * 0.6 + v.data.position[0]) * 0.8
+
+ const dist = this.shipPos.distanceTo(v.group.position)
+ const visitDist = v.data.radius + VISIT_RANGE
+
+ // Discovery: first time within visit range — reward the captain.
+ if (dist < visitDist && !v.discovered) {
+ v.discovered = true
+ v.label.visible = true
+ v.pulse = 1
+ store.discover(v.data)
+ // Celebratory firework at the newly charted world.
+ this.spawnBurst(v.group.position, v.baseColor.clone(), 54, 24)
+ this.spawnBurst(v.group.position, new THREE.Color('#ffe6a8'), 30, 16)
+ if (
+ useVoyageStore.getState().discoveredCount >=
+ this.planetViews.length &&
+ !this.completedCelebrated
+ ) {
+ this.completedCelebrated = true
+ this.celebrateCompletion()
+ }
+ }
+
+ // Light up discovered planets in brand color.
+ if (v.discovered) {
+ v.coreMat.color.lerp(v.baseColor, 1 - Math.exp(-3 * delta))
+ v.coreMat.emissive.lerp(v.baseColor, 1 - Math.exp(-3 * delta))
+ v.coreMat.emissiveIntensity = THREE.MathUtils.lerp(
+ v.coreMat.emissiveIntensity,
+ 0.85,
+ 1 - Math.exp(-3 * delta),
+ )
+ v.haloMat.opacity = THREE.MathUtils.lerp(
+ v.haloMat.opacity,
+ 0.32,
+ 1 - Math.exp(-3 * delta),
+ )
+ const ringMat = v.ring.material as THREE.MeshBasicMaterial
+ ringMat.opacity = THREE.MathUtils.lerp(
+ ringMat.opacity,
+ 0.5,
+ 1 - Math.exp(-3 * delta),
+ )
+ }
+
+ // Hover highlight + celebratory discovery pop.
+ const targetHover = this.hovered === v ? 1 : 0
+ v.hover += (targetHover - v.hover) * (1 - Math.exp(-8 * delta))
+ v.pulse = Math.max(0, v.pulse - delta * 1.6)
+ const pop = Math.sin(v.pulse * Math.PI) * 0.4
+ const scale = 1 + v.hover * 0.12 + pop
+ v.group.scale.setScalar(scale)
+
+ if (dist < visitDist + 6 && dist < nearestDist) {
+ nearest = v
+ nearestDist = dist
+ }
+ }
+
+ // Update "nearby" HUD target.
+ if (nearest) {
+ store.setNearby({
+ id: nearest.data.id,
+ name: nearest.data.name,
+ shortName: nearest.data.shortName,
+ tagline: nearest.data.tagline,
+ url: nearest.data.url,
+ color: nearest.data.color,
+ })
+ } else {
+ store.setNearby(null)
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Combat
+ // -------------------------------------------------------------------------
+ private updateCombat(delta: number): void {
+ const now = this.clock.getElapsedTime()
+ const invuln = now < this.invulnUntil
+
+ // Hull-hit light flash decay.
+ if (this.flashTimer > 0) {
+ this.flashTimer -= delta
+ this.shipLight.color.setRGB(1, 0.4, 0.3)
+ } else {
+ this.shipLight.color.set('#bfe9ff')
+ }
+
+ // Post-respawn invulnerability blink.
+ this.shipGroup.visible = invuln ? Math.floor(now * 12) % 2 === 0 : true
+
+ // Player respawn countdown.
+ if (this.dead) {
+ this.respawnTimer -= delta
+ if (this.respawnTimer <= 0) this.respawnPlayer()
+ }
+
+ // Player firing (hold Space / fire button).
+ this.fireTimer = Math.max(0, this.fireTimer - delta)
+ if (!this.dead && this.keys.has('Space') && this.fireTimer <= 0) {
+ this.firePlayer()
+ this.fireTimer = PLAYER_FIRE_INTERVAL
+ }
+
+ // Slow hull self-repair once you've been out of the fight for a moment.
+ if (
+ !this.dead &&
+ this.playerHealth < PLAYER_MAX_HEALTH &&
+ now - this.lastDamageTime > REGEN_DELAY
+ ) {
+ this.playerHealth = Math.min(
+ PLAYER_MAX_HEALTH,
+ this.playerHealth + HULL_REGEN * delta,
+ )
+ useVoyageStore.getState().setHealth(Math.ceil(this.playerHealth))
+ }
+
+ this.updateEnemies(delta, now, invuln)
+
+ // Boss gauntlet pacing.
+ if (this.boss) {
+ this.updateBoss(delta, invuln)
+ } else if (this.gauntletActive && this.nextBossTimer > 0) {
+ this.nextBossTimer -= delta
+ if (this.nextBossTimer <= 0) this.spawnBoss(this.bossIndex)
+ }
+
+ this.updateProjectiles(delta, now, invuln)
+ }
+
+ private updateEnemies(delta: number, now: number, invuln: boolean): void {
+ for (let i = 0; i < this.enemies.length; i++) {
+ const e = this.enemies[i]
+
+ if (!e.alive) {
+ e.respawnTimer -= delta
+ if (e.respawnTimer <= 0) this.spawnEnemy(e, i)
+ continue
+ }
+
+ const dx = this.shipPos.x - e.pos.x
+ const dz = this.shipPos.z - e.pos.z
+ const dy = this.shipPos.y - e.bandY
+ const distXZ = Math.hypot(dx, dz)
+ const dist3D = Math.hypot(distXZ, this.shipPos.y - e.pos.y)
+ const sameBand = Math.abs(dy) < ENEMY_BAND_TOLERANCE
+ const aggro = !this.dead && sameBand && dist3D < ENEMY_AGGRO
+
+ let turnDir = 0
+ if (aggro) {
+ // Face and close on the player.
+ const desiredYaw = Math.atan2(dx, dz)
+ const diff = wrapAngle(desiredYaw - e.yaw)
+ turnDir = Math.sign(diff)
+ e.yaw += THREE.MathUtils.clamp(diff, -2.2 * delta, 2.2 * delta)
+ // Hold a firing distance rather than ramming through.
+ const speed =
+ distXZ > 28 ? ENEMY_SPEED : distXZ < 18 ? -ENEMY_SPEED * 0.4 : 0
+ e.pos.x += Math.sin(e.yaw) * speed * delta
+ e.pos.z += Math.cos(e.yaw) * speed * delta
+
+ e.fireTimer -= delta
+ if (e.fireTimer <= 0 && dist3D < ENEMY_FIRE_RANGE && !invuln) {
+ this.fireEnemy(e)
+ e.fireTimer = ENEMY_FIRE_INTERVAL * (0.8 + seeded(now + i) * 0.5)
+ }
+ } else {
+ // Lazy patrol drift.
+ e.yaw += Math.sin(now * 0.3 + e.wanderSeed) * 0.4 * delta
+ const speed = ENEMY_SPEED * 0.35
+ e.pos.x += Math.sin(e.yaw) * speed * delta
+ e.pos.z += Math.cos(e.yaw) * speed * delta
+ }
+
+ // Steer back toward the play area near the boundary.
+ const radial = Math.hypot(e.pos.x, e.pos.z)
+ if (radial > WORLD_RADIUS - 6) {
+ const inward = Math.atan2(-e.pos.x, -e.pos.z)
+ e.yaw += wrapAngle(inward - e.yaw) * Math.min(1, 3 * delta)
+ const s = (WORLD_RADIUS - 6) / radial
+ e.pos.x *= s
+ e.pos.z *= s
+ }
+
+ // Settle to band altitude with a gentle bob.
+ const targetY = e.bandY + Math.sin(now * 0.8 + e.wanderSeed) * 0.8
+ e.pos.y += (targetY - e.pos.y) * (1 - Math.exp(-3 * delta))
+
+ // Banking.
+ const targetRoll = -turnDir * 0.28
+ e.roll += (targetRoll - e.roll) * (1 - Math.exp(-5 * delta))
+
+ // Ramming the player hull hurts (and shoves both apart).
+ if (!this.dead && !invuln && dist3D < PLAYER_RADIUS + 3) {
+ this.damagePlayer(COLLISION_DAMAGE * delta * 2.2)
+ const push = (PLAYER_RADIUS + 3 - dist3D) * 0.5
+ e.pos.x -= (dx / (dist3D || 1)) * push
+ e.pos.z -= (dz / (dist3D || 1)) * push
+ }
+
+ e.group.position.copy(e.pos)
+ e.group.rotation.set(0, e.yaw, e.roll)
+ }
+ }
+
+ private updateProjectiles(delta: number, now: number, invuln: boolean): void {
+ const survivors: Projectile[] = []
+ for (const p of this.projectiles) {
+ p.pos.addScaledVector(p.vel, delta)
+ p.life -= delta
+ let dead = p.life <= 0 || Math.hypot(p.pos.x, p.pos.z) > WORLD_RADIUS + 70
+
+ if (!dead && p.fromPlayer) {
+ for (const e of this.enemies) {
+ if (!e.alive) continue
+ if (p.pos.distanceTo(e.pos) < ENEMY_RADIUS) {
+ e.health -= p.damage
+ this.spawnBurst(p.pos, new THREE.Color('#ffd27a'), 10, 14)
+ if (e.health <= 0) this.destroyEnemy(e)
+ dead = true
+ break
+ }
+ }
+ // Player cannonballs also wound the boss.
+ if (
+ !dead &&
+ this.boss &&
+ p.pos.distanceTo(this.boss.pos) < this.boss.radius
+ ) {
+ this.boss.health -= p.damage
+ this.spawnBurst(
+ p.pos,
+ new THREE.Color(this.boss.config.color),
+ 14,
+ 16,
+ )
+ useVoyageStore.getState().setBossHp(Math.max(0, this.boss.health))
+ if (this.boss.health <= 0) this.defeatBoss()
+ dead = true
+ }
+ } else if (!dead && !p.fromPlayer && !this.dead && !invuln) {
+ if (p.pos.distanceTo(this.shipPos) < PLAYER_RADIUS) {
+ this.damagePlayer(p.damage)
+ this.spawnBurst(p.pos, new THREE.Color('#ff7a4d'), 10, 14)
+ dead = true
+ }
+ }
+
+ if (dead) {
+ this.releaseBall(p.mesh, p.fromPlayer)
+ } else {
+ p.mesh.position.copy(p.pos)
+ survivors.push(p)
+ }
+ }
+ this.projectiles = survivors
+ }
+
+ private firePlayer(): void {
+ const fx = Math.sin(this.yaw)
+ const fz = Math.cos(this.yaw)
+ const bow = new THREE.Vector3(
+ this.shipPos.x + fx * 2.6,
+ this.shipPos.y + 0.4,
+ this.shipPos.z + fz * 2.6,
+ )
+ const vel = new THREE.Vector3(fx, 0, fz).multiplyScalar(PLAYER_BALL_SPEED)
+ this.spawnBall(bow, vel, true, PLAYER_BALL_LIFE, PLAYER_BALL_DAMAGE)
+ this.spawnBurst(bow, new THREE.Color('#ffe6a8'), 6, 9)
+ }
+
+ private fireEnemy(e: Enemy): void {
+ // Lead the target a little based on travel time.
+ const toPlayer = new THREE.Vector3().subVectors(this.shipPos, e.pos)
+ const travel = toPlayer.length() / ENEMY_BALL_SPEED
+ const lead = new THREE.Vector3(
+ Math.sin(this.yaw) * this.velocity,
+ 0,
+ Math.cos(this.yaw) * this.velocity,
+ ).multiplyScalar(travel)
+ const dir = toPlayer.add(lead).normalize()
+ // A little inaccuracy so it's dodgeable.
+ dir.x += (seeded(e.wanderSeed + e.pos.x) - 0.5) * 0.08
+ dir.y += (seeded(e.wanderSeed + e.pos.z) - 0.5) * 0.05
+ dir.normalize()
+ const muzzle = new THREE.Vector3().copy(e.pos).addScaledVector(dir, 2.6)
+ const vel = dir.multiplyScalar(ENEMY_BALL_SPEED)
+ this.spawnBall(muzzle, vel, false, ENEMY_BALL_LIFE, ENEMY_BALL_DAMAGE)
+ this.spawnBurst(muzzle, new THREE.Color('#ff5a4d'), 5, 8)
+ }
+
+ private spawnBall(
+ pos: THREE.Vector3,
+ vel: THREE.Vector3,
+ fromPlayer: boolean,
+ life: number,
+ damage: number,
+ ): void {
+ const mesh = this.acquireBall(fromPlayer)
+ mesh.position.copy(pos)
+ this.projectiles.push({
+ pos: pos.clone(),
+ vel,
+ life,
+ fromPlayer,
+ damage,
+ mesh,
+ })
+ }
+
+ private acquireBall(fromPlayer: boolean): THREE.Mesh {
+ const pool = fromPlayer ? this.ballPool.player : this.ballPool.enemy
+ let mesh = pool.pop()
+ if (!mesh) {
+ mesh = new THREE.Mesh(
+ this.ballGeo,
+ fromPlayer ? this.playerBallMat : this.enemyBallMat,
+ )
+ }
+ mesh.visible = true
+ this.projectileGroup.add(mesh)
+ return mesh
+ }
+
+ private releaseBall(mesh: THREE.Mesh, fromPlayer: boolean): void {
+ this.projectileGroup.remove(mesh)
+ ;(fromPlayer ? this.ballPool.player : this.ballPool.enemy).push(mesh)
+ }
+
+ private destroyEnemy(e: Enemy): void {
+ e.alive = false
+ e.group.visible = false
+ e.respawnTimer = ENEMY_RESPAWN_DELAY
+ this.spawnBurst(e.pos, new THREE.Color('#ff8a3d'), 60, 26)
+ this.spawnBurst(e.pos, new THREE.Color('#ffd27a'), 30, 16)
+ useVoyageStore.getState().addPirateSunk()
+ }
+
+ // -------------------------------------------------------------------------
+ // Boss gauntlet
+ // -------------------------------------------------------------------------
+ /** Kick off the end-game gauntlet (called from the victory screen). */
+ startGauntlet(): void {
+ if (this.gauntletActive || this.gauntletEnded || this.boss) return
+ this.gauntletActive = true
+ this.bossIndex = 0
+ useVoyageStore.getState().setGauntletActive(true)
+ // Clear the roaming pirates for a focused duel.
+ for (const e of this.enemies) {
+ e.alive = false
+ e.group.visible = false
+ e.respawnTimer = 1e9
+ }
+ this.projectiles = this.projectiles.filter((p) => {
+ if (p.fromPlayer) return true
+ this.releaseBall(p.mesh, false)
+ return false
+ })
+ this.spawnBoss(0)
+ }
+
+ private spawnBoss(index: number): void {
+ const cfg = BOSS_LEVELS[index]
+ const group = new THREE.Group()
+ if (modelLoader.isLoaded('/models/ship.glb')) {
+ const model = modelLoader.clone(
+ '/models/ship.glb',
+ new THREE.Color(cfg.color),
+ )
+ model.scale.setScalar(0.7 * cfg.scale)
+ group.add(model)
+ } else {
+ const hull = new THREE.Mesh(
+ new THREE.ConeGeometry(1.4, 4, 8),
+ new THREE.MeshStandardMaterial({ color: cfg.color }),
+ )
+ hull.rotation.x = Math.PI / 2
+ group.add(hull)
+ }
+ const lamp = new THREE.PointLight(new THREE.Color(cfg.color), 2.6, 46, 2)
+ lamp.position.set(0, 3, 0)
+ group.add(lamp)
+
+ // Spawn ahead of the player in their current band.
+ const ahead = 72
+ const pos = new THREE.Vector3(
+ this.shipPos.x + Math.sin(this.yaw) * ahead,
+ this.shipPos.y,
+ this.shipPos.z + Math.cos(this.yaw) * ahead,
+ )
+ const r = Math.hypot(pos.x, pos.z)
+ if (r > WORLD_RADIUS - 10) {
+ const s = (WORLD_RADIUS - 10) / r
+ pos.x *= s
+ pos.z *= s
+ }
+ group.position.copy(pos)
+ this.scene.add(group)
+
+ this.boss = {
+ group,
+ pos,
+ yaw: this.yaw + Math.PI,
+ roll: 0,
+ health: cfg.health,
+ maxHealth: cfg.health,
+ level: index + 1,
+ fireTimer: 2.2,
+ radius: 5 * cfg.scale,
+ config: cfg,
+ }
+ useVoyageStore.getState().setBoss({
+ name: cfg.name,
+ color: cfg.color,
+ level: index + 1,
+ total: BOSS_LEVELS.length,
+ maxHp: cfg.health,
+ })
+ this.spawnBurst(pos, new THREE.Color(cfg.color), 54, 26)
+
+ // Reactivate a few pool pirates as escorts for the higher tiers.
+ let spawned = 0
+ for (const e of this.enemies) {
+ if (spawned >= cfg.escorts) break
+ const ang = (spawned / Math.max(1, cfg.escorts)) * Math.PI * 2
+ e.bandY = this.shipPos.y
+ e.pos.set(
+ pos.x + Math.cos(ang) * 16,
+ this.shipPos.y,
+ pos.z + Math.sin(ang) * 16,
+ )
+ e.yaw = ang
+ e.health = ENEMY_MAX_HEALTH
+ e.alive = true
+ e.respawnTimer = 0
+ e.group.visible = true
+ e.group.position.copy(e.pos)
+ spawned++
+ }
+ }
+
+ private updateBoss(delta: number, invuln: boolean): void {
+ const b = this.boss
+ if (!b) return
+ const to = new THREE.Vector3().subVectors(this.shipPos, b.pos)
+ const dist = to.length()
+
+ const desiredYaw = Math.atan2(to.x, to.z)
+ b.yaw += THREE.MathUtils.clamp(
+ wrapAngle(desiredYaw - b.yaw),
+ -2.4 * delta,
+ 2.4 * delta,
+ )
+ const speed =
+ dist > 34 ? b.config.speed : dist < 20 ? -b.config.speed * 0.5 : 0
+ b.pos.x += Math.sin(b.yaw) * speed * delta
+ b.pos.z += Math.cos(b.yaw) * speed * delta
+ // The boss hunts you across dimensions.
+ b.pos.y += (this.shipPos.y - b.pos.y) * (1 - Math.exp(-2 * delta))
+
+ const r = Math.hypot(b.pos.x, b.pos.z)
+ if (r > WORLD_RADIUS - 8) {
+ const s = (WORLD_RADIUS - 8) / r
+ b.pos.x *= s
+ b.pos.z *= s
+ }
+
+ b.fireTimer -= delta
+ if (b.fireTimer <= 0 && dist < 95 && !this.dead && !invuln) {
+ this.fireBoss(b, dist)
+ b.fireTimer = b.config.fireInterval
+ }
+
+ if (!this.dead && !invuln && dist < PLAYER_RADIUS + b.radius) {
+ this.damagePlayer(COLLISION_DAMAGE * delta * 2.5)
+ const push = (PLAYER_RADIUS + b.radius - dist) * 0.5
+ b.pos.x -= (to.x / (dist || 1)) * push
+ b.pos.z -= (to.z / (dist || 1)) * push
+ }
+
+ b.roll = Math.sin(this.clock.getElapsedTime() * 1.5) * 0.05
+ b.group.position.copy(b.pos)
+ b.group.rotation.set(0, b.yaw, b.roll)
+ }
+
+ private fireBoss(b: Boss, dist: number): void {
+ const base = Math.atan2(this.shipPos.x - b.pos.x, this.shipPos.z - b.pos.z)
+ const dyN = THREE.MathUtils.clamp(
+ (this.shipPos.y - b.pos.y) / Math.max(dist, 1),
+ -0.3,
+ 0.3,
+ )
+ const spread = 0.16
+ const n = b.config.shots
+ for (let k = 0; k < n; k++) {
+ const ang = base + (k - (n - 1) / 2) * spread
+ const dir = new THREE.Vector3(
+ Math.sin(ang),
+ dyN,
+ Math.cos(ang),
+ ).normalize()
+ const muzzle = new THREE.Vector3()
+ .copy(b.pos)
+ .addScaledVector(dir, b.radius + 1)
+ this.spawnBall(
+ muzzle,
+ dir.clone().multiplyScalar(ENEMY_BALL_SPEED),
+ false,
+ ENEMY_BALL_LIFE,
+ b.config.ballDamage,
+ )
+ }
+ this.spawnBurst(b.pos, new THREE.Color(b.config.color), 8, 10)
+ }
+
+ private defeatBoss(): void {
+ const b = this.boss
+ if (!b) return
+ this.spawnBurst(b.pos, new THREE.Color(b.config.color), 80, 30)
+ this.spawnBurst(b.pos, new THREE.Color('#fff0c2'), 44, 20)
+ this.scene.remove(b.group)
+ this.disposeGroup(b.group)
+ this.boss = null
+
+ const store = useVoyageStore.getState()
+ store.setBoss(null)
+ store.addBossDefeated()
+ store.addDoubloons(b.config.reward)
+
+ this.bossIndex++
+ if (this.bossIndex < BOSS_LEVELS.length) {
+ this.nextBossTimer = 3.2
+ } else {
+ this.finishGauntlet()
+ }
+ }
+
+ private finishGauntlet(): void {
+ this.gauntletActive = false
+ this.gauntletEnded = true
+ const store = useVoyageStore.getState()
+ store.setGauntletActive(false)
+ store.setChampion(true)
+ this.celebrateCompletion()
+ // Bring back roaming pirates for continued free play.
+ for (const e of this.enemies) {
+ e.respawnTimer = 1 + Math.random() * 4
+ }
+ }
+
+ private disposeGroup(group: THREE.Object3D): void {
+ group.traverse((child) => {
+ if (child instanceof THREE.Mesh) {
+ child.geometry?.dispose()
+ const m = child.material
+ if (Array.isArray(m)) m.forEach((x) => x.dispose())
+ else m?.dispose()
+ }
+ })
+ }
+
+ private damagePlayer(amount: number): void {
+ if (this.dead) return
+ this.playerHealth = Math.max(0, this.playerHealth - amount)
+ this.flashTimer = 0.18
+ this.lastDamageTime = this.clock.getElapsedTime()
+ useVoyageStore.getState().setHealth(Math.ceil(this.playerHealth))
+ if (this.playerHealth <= 0) this.killPlayer()
+ }
+
+ private killPlayer(): void {
+ this.dead = true
+ this.playerHealth = 0
+ this.velocity = 0
+ this.respawnTimer = RESPAWN_DELAY
+ this.spawnBurst(this.shipPos, new THREE.Color('#ff8a3d'), 90, 32)
+ this.spawnBurst(this.shipPos, new THREE.Color('#fff0c2'), 40, 20)
+ const store = useVoyageStore.getState()
+ store.setHealth(0)
+ store.setShipwrecked(true)
+ }
+
+ private respawnPlayer(): void {
+ this.dead = false
+ this.shipPos.set(0, BANDS[0].y, 0)
+ this.targetBand = 0
+ this.yaw = 0
+ this.velocity = 0
+ this.playerHealth = PLAYER_MAX_HEALTH
+ this.invulnUntil = this.clock.getElapsedTime() + INVULN_TIME
+ // Snap the camera behind the fresh spawn to avoid a wild swing.
+ this.camCurrent.set(0, BANDS[0].y + 12, -26)
+ // Clear any in-flight enemy cannonballs.
+ this.projectiles = this.projectiles.filter((p) => {
+ if (p.fromPlayer) return true
+ this.releaseBall(p.mesh, false)
+ return false
+ })
+ const store = useVoyageStore.getState()
+ store.setHealth(PLAYER_MAX_HEALTH)
+ store.setShipwrecked(false)
+ }
+
+ private spawnBurst(
+ origin: THREE.Vector3,
+ color: THREE.Color,
+ count: number,
+ speed: number,
+ ): void {
+ const now = this.clock.getElapsedTime() * 1000
+ for (let i = 0; i < count; i++) {
+ if (this.bursts.length >= BURST_MAX) break
+ const dir = new THREE.Vector3(
+ Math.random() - 0.5,
+ Math.random() - 0.5,
+ Math.random() - 0.5,
+ ).normalize()
+ this.bursts.push({
+ pos: origin.clone(),
+ vel: dir.multiplyScalar(speed * (0.4 + Math.random() * 0.6)),
+ birth: now,
+ life: 350 + Math.random() * 500,
+ size: 1.4 + Math.random() * 2.4,
+ color,
+ })
+ }
+ }
+
+ /** Big fireworks finale when every world has been charted. */
+ private celebrateCompletion(): void {
+ const colors = ['#ffd27a', '#7ad1ff', '#ff7ad1', '#9bff8a', '#fff0c2']
+ const fire = (wave: number) => {
+ if (this.disposed) return
+ for (let i = 0; i < 5; i++) {
+ const ang = Math.random() * Math.PI * 2
+ const r = 12 + Math.random() * 26
+ const origin = new THREE.Vector3(
+ this.shipPos.x + Math.cos(ang) * r,
+ this.shipPos.y + 4 + Math.random() * 14,
+ this.shipPos.z + Math.sin(ang) * r,
+ )
+ const color = new THREE.Color(colors[(i + wave) % colors.length])
+ this.spawnBurst(origin, color, 40, 22)
+ }
+ }
+ // A few staggered volleys for a proper send-off.
+ fire(0)
+ for (let w = 1; w <= 5; w++) {
+ setTimeout(() => fire(w), w * 450)
+ }
+ }
+
+ private updateBursts(delta: number): void {
+ const now = this.clock.getElapsedTime() * 1000
+ const drag = Math.exp(-2.4 * delta)
+ this.bursts = this.bursts.filter((b) => now - b.birth < b.life)
+ const positions = this.burstGeo.attributes.position as THREE.BufferAttribute
+ const colors = this.burstGeo.attributes.color as THREE.BufferAttribute
+ const sizes = this.burstGeo.attributes.size as THREE.BufferAttribute
+ const count = Math.min(this.bursts.length, BURST_MAX)
+ for (let i = 0; i < count; i++) {
+ const b = this.bursts[i]
+ b.pos.addScaledVector(b.vel, delta)
+ b.vel.multiplyScalar(drag)
+ const fade = 1 - (now - b.birth) / b.life
+ positions.setXYZ(i, b.pos.x, b.pos.y, b.pos.z)
+ colors.setXYZ(i, b.color.r * fade, b.color.g * fade, b.color.b * fade)
+ sizes.setX(i, b.size * fade)
+ }
+ this.burstGeo.setDrawRange(0, count)
+ positions.needsUpdate = true
+ colors.needsUpdate = true
+ sizes.needsUpdate = true
+ }
+
+ private updateEnvironment(delta: number): void {
+ // Slowly drift the starfield + nebula for parallax life.
+ if (this.starField) this.starField.rotation.y += delta * 0.004
+ if (this.nebula) this.nebula.rotation.y -= delta * 0.002
+
+ // Tint the fog + ambient toward the current band's color so each
+ // dimension feels distinct.
+ const band = BANDS[this.targetBand]
+ const bandColor = new THREE.Color(band.color)
+ this.fogTinted.copy(this.fogBase).lerp(bandColor, 0.12)
+ this.fog.color.lerp(this.fogTinted, 1 - Math.exp(-1.5 * delta))
+ this.ambient.color.lerp(
+ new THREE.Color('#8899ff').lerp(bandColor, 0.25),
+ 1 - Math.exp(-1.5 * delta),
+ )
+ }
+
+ // -------------------------------------------------------------------------
+ // Resize / dispose
+ // -------------------------------------------------------------------------
+ resize(width: number, height: number): void {
+ if (width === 0 || height === 0) return
+ const dpr = Math.min(window.devicePixelRatio, 2)
+ this.canvas.width = width * dpr
+ this.canvas.height = height * dpr
+ this.canvas.style.width = `${width}px`
+ this.canvas.style.height = `${height}px`
+ this.camera.aspect = width / height
+ this.camera.updateProjectionMatrix()
+ this.renderer.setSize(width, height, false)
+ }
+
+ dispose(): void {
+ this.disposed = true
+ this.stop()
+
+ window.removeEventListener('keydown', this.boundKeyDown)
+ window.removeEventListener('keyup', this.boundKeyUp)
+ document.removeEventListener('visibilitychange', this.handleVisibility)
+ this.canvas.removeEventListener('click', this.handleClick)
+ this.canvas.removeEventListener('pointermove', this.handlePointerMove)
+
+ this.scene.traverse((obj) => {
+ if (obj instanceof THREE.Mesh || obj instanceof THREE.Points) {
+ obj.geometry?.dispose()
+ const mat = obj.material
+ if (Array.isArray(mat)) mat.forEach((m) => m.dispose())
+ else mat?.dispose()
+ }
+ if (obj instanceof THREE.Sprite) {
+ obj.material.map?.dispose()
+ obj.material.dispose()
+ }
+ })
+
+ // Shared combat resources (pooled cannonballs may not be in the scene).
+ this.ballGeo.dispose()
+ this.playerBallMat.dispose()
+ this.enemyBallMat.dispose()
+ this.burstMat.map?.dispose()
+
+ try {
+ this.renderer.dispose()
+ } catch (e) {
+ console.warn('[VoyageEngine] renderer dispose error:', e)
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/** Deterministic pseudo-random in [0,1) from a numeric seed. */
+function seeded(n: number): number {
+ const x = Math.sin(n * 127.1 + 311.7) * 43758.5453
+ return x - Math.floor(x)
+}
+
+/** Wrap an angle to (-PI, PI]. */
+function wrapAngle(a: number): number {
+ while (a > Math.PI) a -= Math.PI * 2
+ while (a < -Math.PI) a += Math.PI * 2
+ return a
+}
+
+function makeSoftCircleTexture(): THREE.CanvasTexture {
+ const canvas = document.createElement('canvas')
+ canvas.width = 64
+ canvas.height = 64
+ const ctx = canvas.getContext('2d')!
+ const g = ctx.createRadialGradient(32, 32, 0, 32, 32, 32)
+ g.addColorStop(0, 'rgba(255,255,255,1)')
+ g.addColorStop(0.35, 'rgba(255,255,255,0.85)')
+ g.addColorStop(1, 'rgba(255,255,255,0)')
+ ctx.fillStyle = g
+ ctx.fillRect(0, 0, 64, 64)
+ return new THREE.CanvasTexture(canvas)
+}
+
+function makeLabelSprite(text: string, color: string): THREE.Sprite {
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')!
+ const font = 'bold 52px Inter, system-ui, sans-serif'
+ ctx.font = font
+ const padding = 36
+ const textWidth = ctx.measureText(text).width
+ canvas.width = Math.ceil(textWidth + padding * 2)
+ canvas.height = 128
+
+ // Pill background.
+ ctx.font = font
+ ctx.fillStyle = 'rgba(6, 10, 22, 0.72)'
+ roundRect(ctx, 0, 28, canvas.width, 72, 36)
+ ctx.fill()
+ ctx.lineWidth = 3
+ ctx.strokeStyle = color
+ roundRect(ctx, 1.5, 29.5, canvas.width - 3, 69, 34.5)
+ ctx.stroke()
+
+ // Text.
+ ctx.fillStyle = '#ffffff'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.fillText(text, canvas.width / 2, 65)
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.minFilter = THREE.LinearFilter
+ const mat = new THREE.SpriteMaterial({
+ map: texture,
+ transparent: true,
+ depthWrite: false,
+ depthTest: false,
+ })
+ const sprite = new THREE.Sprite(mat)
+ const aspect = canvas.width / canvas.height
+ const h = 4.4
+ sprite.scale.set(h * aspect, h, 1)
+ return sprite
+}
+
+function roundRect(
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ r: number,
+): void {
+ ctx.beginPath()
+ ctx.moveTo(x + r, y)
+ ctx.arcTo(x + w, y, x + w, y + h, r)
+ ctx.arcTo(x + w, y + h, x, y + h, r)
+ ctx.arcTo(x, y + h, x, y, r)
+ ctx.arcTo(x, y, x + w, y, r)
+ ctx.closePath()
+}
diff --git a/src/components/voyage/planets.ts b/src/components/voyage/planets.ts
new file mode 100644
index 00000000..74369eec
--- /dev/null
+++ b/src/components/voyage/planets.ts
@@ -0,0 +1,131 @@
+// Data for the Space Voyage page (/voyage).
+//
+// Each TanStack library becomes a "planet" floating in one of three altitude
+// bands ("dimensions"). The player flies a star-galleon between bands to
+// discover them all — go high, go low.
+
+import { libraries } from '~/libraries'
+
+export interface VoyageBand {
+ index: number
+ name: string
+ subtitle: string
+ /** World-space Y altitude for this band. */
+ y: number
+ /** Accent color used for nebula tint + HUD. */
+ color: string
+}
+
+export interface Planet {
+ id: string
+ /** Full library name, e.g. "TanStack Query". */
+ name: string
+ /** Short label without the "TanStack " prefix. */
+ shortName: string
+ tagline: string
+ /** Where clicking the planet navigates. */
+ url: string
+ /** Hex brand color. */
+ color: string
+ band: number
+ position: [number, number, number]
+ radius: number
+}
+
+// Three stacked dimensions the player ascends/descends between.
+export const BANDS: VoyageBand[] = [
+ {
+ index: 0,
+ name: 'The Shallows',
+ subtitle: 'low orbit',
+ y: 0,
+ color: '#22d3ee',
+ },
+ {
+ index: 1,
+ name: 'The Drift',
+ subtitle: 'mid orbit',
+ y: 78,
+ color: '#a78bfa',
+ },
+ {
+ index: 2,
+ name: 'The High Reaches',
+ subtitle: 'high orbit',
+ y: 156,
+ color: '#fbbf24',
+ },
+]
+
+// Brand color per library id (hex, derived from each library's Tailwind theme).
+const PLANET_COLORS: Record = {
+ query: '#ef4444',
+ router: '#10b981',
+ start: '#14b8a6',
+ table: '#06b6d4',
+ form: '#eab308',
+ db: '#f97316',
+ ai: '#ec4899',
+ intent: '#0ea5e9',
+ virtual: '#a855f7',
+ pacer: '#84cc16',
+ hotkeys: '#f43f5e',
+ store: '#c2a06b',
+ ranger: '#9ca3af',
+ config: '#94a3b8',
+ devtools: '#cbd5e1',
+ mcp: '#d1d5db',
+ cli: '#6366f1',
+ workflow: '#2563eb',
+}
+
+const GOLDEN_ANGLE = 2.399963229728653
+const RING_RADII = [46, 74, 102]
+
+/**
+ * Build the planet list deterministically (no Math.random at module load so
+ * positions are stable across reloads). Libraries are round-robined across the
+ * three bands and scattered on golden-angle spokes within each band.
+ */
+function buildPlanets(): Planet[] {
+ const navigable = libraries.filter((lib) => lib.to && lib.tagline)
+
+ // Bucket libraries per band, balanced round-robin.
+ const buckets: Array = [[], [], []]
+ navigable.forEach((lib, i) => {
+ buckets[i % BANDS.length].push(lib)
+ })
+
+ const planets: Planet[] = []
+
+ buckets.forEach((bucket, bandIndex) => {
+ const band = BANDS[bandIndex]
+ bucket.forEach((lib, j) => {
+ const angle = j * GOLDEN_ANGLE + bandIndex * 1.1
+ const ringRadius = RING_RADII[j % RING_RADII.length]
+ // Deterministic vertical jitter so planets don't sit on a perfect plane.
+ const jitter = Math.sin(j * 12.9898 + bandIndex * 4.1) * 7
+
+ planets.push({
+ id: lib.id,
+ name: lib.name,
+ shortName: lib.name.replace(/^TanStack\s+/, ''),
+ tagline: lib.tagline,
+ url: lib.to!,
+ color: PLANET_COLORS[lib.id] ?? '#64748b',
+ band: bandIndex,
+ position: [
+ Math.cos(angle) * ringRadius,
+ band.y + jitter,
+ Math.sin(angle) * ringRadius,
+ ],
+ radius: 5.5 + (j % 3) * 1.6,
+ })
+ })
+ })
+
+ return planets
+}
+
+export const PLANETS: Planet[] = buildPlanets()
+export const TOTAL_PLANETS = PLANETS.length
diff --git a/src/components/voyage/store.ts b/src/components/voyage/store.ts
new file mode 100644
index 00000000..d10cee00
--- /dev/null
+++ b/src/components/voyage/store.ts
@@ -0,0 +1,172 @@
+// Lightweight store bridging the imperative Three.js VoyageEngine and the
+// React HUD. The engine writes; the HUD reads. Kept deliberately small — no
+// persistence, no game logic, just the handful of values the overlay shows.
+
+import { create } from 'zustand'
+import { TOTAL_PLANETS, type Planet } from './planets'
+
+/** Doubloons awarded for charting a new world. */
+export const DOUBLOONS_PER_WORLD = 250
+
+export interface NearbyPlanet {
+ id: string
+ name: string
+ shortName: string
+ tagline: string
+ url: string
+ color: string
+}
+
+/** Most recently charted world — drives the celebratory toast. */
+export interface ChartEvent {
+ id: string
+ name: string
+ shortName: string
+ color: string
+ /** Increments each discovery so the HUD can re-trigger the toast. */
+ tick: number
+}
+
+/** The boss currently being fought in the end-game gauntlet. */
+export interface BossInfo {
+ name: string
+ color: string
+ /** 1-based level. */
+ level: number
+ total: number
+ maxHp: number
+}
+
+interface VoyageState {
+ /** Index into BANDS the ship is currently closest to. */
+ bandIndex: number
+ /** Smooth 0..(BANDS.length-1) altitude for the HUD gauge. */
+ altitude: number
+ /** Set of discovered planet ids. */
+ discovered: Set
+ discoveredCount: number
+ /** Planet the ship is hovering near (close enough to visit), or null. */
+ nearby: NearbyPlanet | null
+
+ // Rewards
+ /** Treasure earned from charting worlds. */
+ doubloons: number
+ /** True once every world has been charted. */
+ completed: boolean
+ /** The latest charted world, for the toast. */
+ lastCharted: ChartEvent | null
+
+ // Combat
+ /** Player hull 0..maxHealth. */
+ health: number
+ maxHealth: number
+ /** Pirate ships destroyed. */
+ piratesSunk: number
+ /** True while the ship is wrecked and respawning. */
+ shipwrecked: boolean
+
+ // End-game boss gauntlet
+ /** True while the Pirate Armada gauntlet is underway. */
+ gauntletActive: boolean
+ /** Current boss meta, or null between bosses. */
+ boss: BossInfo | null
+ /** Current boss hull (0..boss.maxHp). */
+ bossHp: number
+ /** Bosses defeated this run. */
+ bossesDefeated: number
+ /** True once the whole gauntlet is cleared. */
+ champion: boolean
+
+ setBand: (bandIndex: number, altitude: number) => void
+ discover: (planet: Planet) => void
+ setNearby: (nearby: NearbyPlanet | null) => void
+ setHealth: (health: number) => void
+ addPirateSunk: () => void
+ addDoubloons: (amount: number) => void
+ setShipwrecked: (wrecked: boolean) => void
+ setGauntletActive: (active: boolean) => void
+ setBoss: (boss: BossInfo | null) => void
+ setBossHp: (hp: number) => void
+ addBossDefeated: () => void
+ setChampion: (champion: boolean) => void
+}
+
+export const PLAYER_MAX_HEALTH = 100
+
+export const useVoyageStore = create((set) => ({
+ bandIndex: 0,
+ altitude: 0,
+ discovered: new Set(),
+ discoveredCount: 0,
+ nearby: null,
+
+ doubloons: 0,
+ completed: false,
+ lastCharted: null,
+
+ health: PLAYER_MAX_HEALTH,
+ maxHealth: PLAYER_MAX_HEALTH,
+ piratesSunk: 0,
+ shipwrecked: false,
+
+ gauntletActive: false,
+ boss: null,
+ bossHp: 0,
+ bossesDefeated: 0,
+ champion: false,
+
+ setBand: (bandIndex, altitude) =>
+ set((s) =>
+ s.bandIndex === bandIndex && s.altitude === altitude
+ ? s
+ : { bandIndex, altitude },
+ ),
+
+ discover: (planet) =>
+ set((s) => {
+ if (s.discovered.has(planet.id)) return s
+ const discovered = new Set(s.discovered)
+ discovered.add(planet.id)
+ const discoveredCount = discovered.size
+ return {
+ discovered,
+ discoveredCount,
+ doubloons: s.doubloons + DOUBLOONS_PER_WORLD,
+ completed: discoveredCount >= TOTAL_PLANETS,
+ lastCharted: {
+ id: planet.id,
+ name: planet.name,
+ shortName: planet.shortName,
+ color: planet.color,
+ tick: (s.lastCharted?.tick ?? 0) + 1,
+ },
+ }
+ }),
+
+ setNearby: (nearby) =>
+ set((s) => {
+ if (s.nearby?.id === nearby?.id) return s
+ return { nearby }
+ }),
+
+ setHealth: (health) => set((s) => (s.health === health ? s : { health })),
+
+ addPirateSunk: () => set((s) => ({ piratesSunk: s.piratesSunk + 1 })),
+
+ addDoubloons: (amount) => set((s) => ({ doubloons: s.doubloons + amount })),
+
+ setShipwrecked: (shipwrecked) =>
+ set((s) => (s.shipwrecked === shipwrecked ? s : { shipwrecked })),
+
+ setGauntletActive: (gauntletActive) =>
+ set((s) => (s.gauntletActive === gauntletActive ? s : { gauntletActive })),
+
+ setBoss: (boss) => set({ boss, bossHp: boss ? boss.maxHp : 0 }),
+
+ setBossHp: (bossHp) => set((s) => (s.bossHp === bossHp ? s : { bossHp })),
+
+ addBossDefeated: () => set((s) => ({ bossesDefeated: s.bossesDefeated + 1 })),
+
+ setChampion: (champion) =>
+ set((s) => (s.champion === champion ? s : { champion })),
+}))
diff --git a/src/components/voyage/ui/VoyageHUD.tsx b/src/components/voyage/ui/VoyageHUD.tsx
new file mode 100644
index 00000000..c313bdf0
--- /dev/null
+++ b/src/components/voyage/ui/VoyageHUD.tsx
@@ -0,0 +1,607 @@
+import { useEffect, useState } from 'react'
+import {
+ ChevronUp,
+ ChevronDown,
+ Compass,
+ Sparkles,
+ Skull,
+ Heart,
+ Coins,
+ Trophy,
+ Swords,
+ Crown,
+} from 'lucide-react'
+import { DOUBLOONS_PER_WORLD, useVoyageStore } from '../store'
+import { BANDS, TOTAL_PLANETS } from '../planets'
+import type { VoyageEngine } from '../engine/VoyageEngine'
+
+export function VoyageHUD({ engine }: { engine: VoyageEngine | null }) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Center aim crosshair
+// ---------------------------------------------------------------------------
+function Crosshair() {
+ const shipwrecked = useVoyageStore((s) => s.shipwrecked)
+ if (shipwrecked) return null
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Intro / controls hint
+// ---------------------------------------------------------------------------
+function IntroOverlay() {
+ const [show, setShow] = useState(true)
+
+ useEffect(() => {
+ if (!show) return
+ const timer = setTimeout(() => setShow(false), 9000)
+ const dismiss = () => setShow(false)
+ window.addEventListener('keydown', dismiss)
+ window.addEventListener('touchstart', dismiss)
+ return () => {
+ clearTimeout(timer)
+ window.removeEventListener('keydown', dismiss)
+ window.removeEventListener('touchstart', dismiss)
+ }
+ }, [show])
+
+ if (!show) return null
+
+ return (
+
+
+
Weigh anchor, captain ⚓
+
+ Chart every TanStack world — and blast the pirates guarding them.
+
+
+
+
+
+
+
+
+ fly close to a world to chart it · click it to visit · press any key
+ to begin
+
+
+
+ )
+}
+
+function Hint({ keys, label }: { keys: string[]; label: string }) {
+ return (
+
+
+ {keys.map((k) => (
+
+ {k}
+
+ ))}
+
+ {label}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Altitude band indicator (the three dimensions)
+// ---------------------------------------------------------------------------
+function BandIndicator({ engine }: { engine: VoyageEngine | null }) {
+ const bandIndex = useVoyageStore((s) => s.bandIndex)
+ const altitude = useVoyageStore((s) => s.altitude)
+
+ // Render top band first (high) so the stack reads vertically like altitude.
+ const ordered = [...BANDS].reverse()
+ const maxIndex = BANDS.length - 1
+ // 0 at top, maxIndex at bottom → invert altitude for the marker.
+ const markerPct = ((maxIndex - altitude) / maxIndex) * 100
+
+ return (
+
+ {/* Vertical gauge */}
+
+
+
+ {ordered.map((band) => {
+ const active = band.index === bandIndex
+ return (
+
engine?.changeBand(band.index - bandIndex)}
+ className={`text-left rounded-lg px-3 py-1.5 backdrop-blur-md ring-1 transition-all ${
+ active
+ ? 'bg-black/55 ring-white/30 scale-100'
+ : 'bg-black/30 ring-white/5 opacity-60 hover:opacity-90 scale-95'
+ }`}
+ >
+
+
+
+ {band.name}
+
+
+
+ {band.subtitle}
+
+
+ )
+ })}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Discovery progress
+// ---------------------------------------------------------------------------
+function DiscoveryProgress() {
+ const count = useVoyageStore((s) => s.discoveredCount)
+ const doubloons = useVoyageStore((s) => s.doubloons)
+ const pct = Math.round((count / TOTAL_PLANETS) * 100)
+ const complete = count >= TOTAL_PLANETS
+
+ return (
+
+
+
+ {complete ? (
+
+ ) : (
+
+ )}
+ {complete ? 'All worlds charted!' : 'Worlds charted'}
+
+
+ {count}
+ / {TOTAL_PLANETS}
+
+
+
+
+
+ {doubloons.toLocaleString()}
+
+ doubloons
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Combat status — hull integrity + pirates sunk
+// ---------------------------------------------------------------------------
+function CombatStatus() {
+ const health = useVoyageStore((s) => s.health)
+ const maxHealth = useVoyageStore((s) => s.maxHealth)
+ const piratesSunk = useVoyageStore((s) => s.piratesSunk)
+ const pct = Math.max(0, Math.round((health / maxHealth) * 100))
+ const low = pct <= 30
+
+ return (
+
+
+ {/* Hull */}
+
+
+ {/* Pirates sunk */}
+
+
+
+ {piratesSunk}
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Shipwrecked overlay
+// ---------------------------------------------------------------------------
+function ShipwreckedOverlay() {
+ const shipwrecked = useVoyageStore((s) => s.shipwrecked)
+ if (!shipwrecked) return null
+ return (
+
+
+
+
Shipwrecked!
+
+ Salvaging a new galleon…
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Nearby planet card
+// ---------------------------------------------------------------------------
+function NearbyCard() {
+ const nearby = useVoyageStore((s) => s.nearby)
+ if (!nearby) return null
+
+ return (
+
+
+
+
+
+
+ {nearby.name}
+
+
+ {nearby.tagline}
+
+
+
+ Visit →
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// "World charted" reward toast
+// ---------------------------------------------------------------------------
+function ChartToast() {
+ const lastCharted = useVoyageStore((s) => s.lastCharted)
+ const [visible, setVisible] = useState(false)
+ const tick = lastCharted?.tick ?? 0
+
+ useEffect(() => {
+ if (!tick) return
+ setVisible(true)
+ const t = setTimeout(() => setVisible(false), 3200)
+ return () => clearTimeout(t)
+ }, [tick])
+
+ if (!lastCharted || !visible) return null
+
+ return (
+
+
+
+
+ New world charted!
+
+
+ {lastCharted.name}
+
+
+
+ +{DOUBLOONS_PER_WORLD} doubloons
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Victory overlay — all worlds charted, unlocks the boss gauntlet
+// ---------------------------------------------------------------------------
+function VictoryOverlay({ engine }: { engine: VoyageEngine | null }) {
+ const completed = useVoyageStore((s) => s.completed)
+ const gauntletActive = useVoyageStore((s) => s.gauntletActive)
+ const champion = useVoyageStore((s) => s.champion)
+ const piratesSunk = useVoyageStore((s) => s.piratesSunk)
+ const doubloons = useVoyageStore((s) => s.doubloons)
+ const [dismissed, setDismissed] = useState(false)
+
+ if (!completed || dismissed || gauntletActive || champion) return null
+
+ return (
+
+
+
+
Voyage Complete!
+
+ You’ve charted every TanStack world. But the Pirate Armada
+ awaits…
+
+
+
+
+
+
+
+
+
{
+ engine?.startGauntlet()
+ setDismissed(true)
+ }}
+ className="mt-6 w-full rounded-xl bg-red-500 hover:bg-red-400 text-white font-bold py-2.5 transition-colors flex items-center justify-center gap-2"
+ >
+ Face the Pirate Armada
+
+
setDismissed(true)}
+ className="mt-2 w-full rounded-xl bg-white/10 hover:bg-white/20 text-white/80 font-semibold py-2 transition-colors text-sm"
+ >
+ Keep sailing ⚓
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Boss health bar (shown during the gauntlet)
+// ---------------------------------------------------------------------------
+function BossBar() {
+ const boss = useVoyageStore((s) => s.boss)
+ const bossHp = useVoyageStore((s) => s.bossHp)
+ if (!boss) return null
+ const pct = Math.max(0, Math.round((bossHp / boss.maxHp) * 100))
+
+ return (
+
+
+
+
+
+ {boss.name}
+
+
+ Boss {boss.level}/{boss.total}
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// "Face the Armada" prompt — re-offer the gauntlet after dismissing victory
+// ---------------------------------------------------------------------------
+function ArmadaPrompt({ engine }: { engine: VoyageEngine | null }) {
+ const completed = useVoyageStore((s) => s.completed)
+ const gauntletActive = useVoyageStore((s) => s.gauntletActive)
+ const champion = useVoyageStore((s) => s.champion)
+ if (!completed || gauntletActive || champion) return null
+
+ return (
+ engine?.startGauntlet()}
+ className="absolute bottom-6 right-4 z-30 pointer-events-auto rounded-xl bg-red-500/80 hover:bg-red-400 text-white font-bold px-4 py-2.5 shadow-2xl ring-1 ring-red-300/40 flex items-center gap-2 animate-pulse"
+ >
+ Face the Armada
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Grand Champion overlay — gauntlet cleared
+// ---------------------------------------------------------------------------
+function ChampionOverlay() {
+ const champion = useVoyageStore((s) => s.champion)
+ const piratesSunk = useVoyageStore((s) => s.piratesSunk)
+ const bossesDefeated = useVoyageStore((s) => s.bossesDefeated)
+ const doubloons = useVoyageStore((s) => s.doubloons)
+ const [dismissed, setDismissed] = useState(false)
+
+ if (!champion || dismissed) return null
+
+ return (
+
+
+
+
Grand Champion!
+
+ The Pirate Armada is vanquished. The cosmos is yours, captain.
+
+
+
+
+
+
+
+
+
setDismissed(true)}
+ className="mt-6 w-full rounded-xl bg-amber-400 hover:bg-amber-300 text-black font-bold py-2.5 transition-colors"
+ >
+ Sail on, legend ⚓
+
+
+
+ )
+}
+
+function Stat({ label, value }: { label: string; value: string }) {
+ return (
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Touch controls (mobile / pointer)
+// ---------------------------------------------------------------------------
+function TouchControls({ engine }: { engine: VoyageEngine | null }) {
+ if (!engine) return null
+
+ const hold = (code: string) => ({
+ onPointerDown: (e: React.PointerEvent) => {
+ e.preventDefault()
+ engine.setKey(code, true)
+ },
+ onPointerUp: () => engine.setKey(code, false),
+ onPointerLeave: () => engine.setKey(code, false),
+ onPointerCancel: () => engine.setKey(code, false),
+ })
+
+ return (
+
+
+ {/* Left: movement d-pad */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Right: fire + climb/dive */}
+
+
+ }
+ onClick={() => engine.changeBand(1)}
+ />
+ }
+ onClick={() => engine.changeBand(-1)}
+ />
+
+
+ FIRE
+
+
+
+
+ )
+}
+
+function TouchBtn({
+ label,
+ ...props
+}: {
+ label: React.ReactNode
+} & React.ComponentProps<'button'>) {
+ return (
+
+ {label}
+
+ )
+}
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index fbb25e07..1a7c8c78 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -10,6 +10,7 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as WorkshopsRouteImport } from './routes/workshops'
+import { Route as VoyageRouteImport } from './routes/voyage'
import { Route as TermsRouteImport } from './routes/terms'
import { Route as TenetsRouteImport } from './routes/tenets'
import { Route as SupportRouteImport } from './routes/support'
@@ -161,6 +162,11 @@ const WorkshopsRoute = WorkshopsRouteImport.update({
path: '/workshops',
getParentRoute: () => rootRouteImport,
} as any)
+const VoyageRoute = VoyageRouteImport.update({
+ id: '/voyage',
+ path: '/voyage',
+ getParentRoute: () => rootRouteImport,
+} as any)
const TermsRoute = TermsRouteImport.update({
id: '/terms',
path: '/terms',
@@ -942,6 +948,7 @@ export interface FileRoutesByFullPath {
'/support': typeof SupportRoute
'/tenets': typeof TenetsRoute
'/terms': typeof TermsRoute
+ '/voyage': typeof VoyageRoute
'/workshops': typeof WorkshopsRoute
'/$libraryId/$version': typeof LibraryIdVersionRouteWithChildren
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
@@ -1083,6 +1090,7 @@ export interface FileRoutesByTo {
'/support': typeof SupportRoute
'/tenets': typeof TenetsRoute
'/terms': typeof TermsRoute
+ '/voyage': typeof VoyageRoute
'/workshops': typeof WorkshopsRoute
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
'/account/feedback': typeof AccountFeedbackRoute
@@ -1229,6 +1237,7 @@ export interface FileRoutesById {
'/support': typeof SupportRoute
'/tenets': typeof TenetsRoute
'/terms': typeof TermsRoute
+ '/voyage': typeof VoyageRoute
'/workshops': typeof WorkshopsRoute
'/$libraryId/$version': typeof LibraryIdVersionRouteWithChildren
'/.well-known/oauth-authorization-server': typeof DotwellKnownOauthAuthorizationServerRoute
@@ -1379,6 +1388,7 @@ export interface FileRouteTypes {
| '/support'
| '/tenets'
| '/terms'
+ | '/voyage'
| '/workshops'
| '/$libraryId/$version'
| '/.well-known/oauth-authorization-server'
@@ -1520,6 +1530,7 @@ export interface FileRouteTypes {
| '/support'
| '/tenets'
| '/terms'
+ | '/voyage'
| '/workshops'
| '/.well-known/oauth-authorization-server'
| '/account/feedback'
@@ -1665,6 +1676,7 @@ export interface FileRouteTypes {
| '/support'
| '/tenets'
| '/terms'
+ | '/voyage'
| '/workshops'
| '/$libraryId/$version'
| '/.well-known/oauth-authorization-server'
@@ -1814,6 +1826,7 @@ export interface RootRouteChildren {
SupportRoute: typeof SupportRoute
TenetsRoute: typeof TenetsRoute
TermsRoute: typeof TermsRoute
+ VoyageRoute: typeof VoyageRoute
WorkshopsRoute: typeof WorkshopsRoute
DotwellKnownOauthAuthorizationServerRoute: typeof DotwellKnownOauthAuthorizationServerRoute
ApiUploadthingRoute: typeof ApiUploadthingRoute
@@ -1887,6 +1900,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof WorkshopsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/voyage': {
+ id: '/voyage'
+ path: '/voyage'
+ fullPath: '/voyage'
+ preLoaderRoute: typeof VoyageRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/terms': {
id: '/terms'
path: '/terms'
@@ -3157,6 +3177,7 @@ const rootRouteChildren: RootRouteChildren = {
SupportRoute: SupportRoute,
TenetsRoute: TenetsRoute,
TermsRoute: TermsRoute,
+ VoyageRoute: VoyageRoute,
WorkshopsRoute: WorkshopsRoute,
DotwellKnownOauthAuthorizationServerRoute:
DotwellKnownOauthAuthorizationServerRoute,
diff --git a/src/routes/voyage.tsx b/src/routes/voyage.tsx
new file mode 100644
index 00000000..b7a1b75f
--- /dev/null
+++ b/src/routes/voyage.tsx
@@ -0,0 +1,54 @@
+import { Link, createFileRoute } from '@tanstack/react-router'
+import { lazy, Suspense } from 'react'
+// /voyage — space-flight cousin of /explore
+
+// Lazy load the whole experience to keep the Three.js bundle out of the main chunk.
+const SpaceVoyage = lazy(() => import('~/components/voyage/SpaceVoyage'))
+
+export const Route = createFileRoute('/voyage')({
+ component: VoyagePage,
+ head: () => ({
+ meta: [
+ {
+ title: 'Space Voyage | TanStack',
+ },
+ {
+ name: 'description',
+ content:
+ 'Captain a flying star-galleon through three dimensions of deep space to discover every TanStack library in this fun 3D voyage.',
+ },
+ ],
+ }),
+ staticData: {
+ Title: () => {
+ return (
+
+ Voyage
+
+ )
+ },
+ },
+})
+
+function LoadingScreen() {
+ return (
+
+
+
+
Charting the stars...
+
Loading voyage engine
+
+
+ )
+}
+
+function VoyagePage() {
+ return (
+ }>
+
+
+ )
+}