Search indexing, AI crawler access and post-deployment verification are covered in Search and AI discovery.
Exora turns catalogued astronomy into a place you can stand in. It reads confirmed exoplanets from the NASA Exoplanet Archive and stars from SIMBAD, derives a deterministic visual recipe from each object's measured properties, and renders that recipe as a real-time Babylon.js world you can orbit on a desktop or place on a table in phone AR.
Every world is a reading of a catalog row, never observed imagery. Exora keeps the three tiers apart at the type level and in the interface: measured values are printed verbatim from the archive, derived values come from established physics applied to those measurements, and inferred appearance is a cautious probabilistic read carrying its own confidence. A field the catalog never reported stays null rather than being backfilled with a plausible number.
The system diorama is where that discipline is most visible, because a picture of a whole system cannot be drawn without compressing it: the orbits are measured, the mapping onto a room is derived, the appearance is inferred, and the view prints the two compressions and the clock rate rather than letting the layout imply it is linear.
- Confirmed-planet catalog: Search, browse, and switch worlds across a weekly snapshot of the NASA Exoplanet Archive, answered in the browser once loaded and backed by live archive queries for newer planets, with twelve curated discovery collections from
earth-likeandocean-candidatesthroughlava-worldsandrecord-breakers. - System diorama: A whole host system as a place to stand inside — the star at the centre, every confirmed world on the orbit the archive measured for it, turning at its own measured period. Orbit radii span decades within one host and bodies are four orders of magnitude smaller again, so both scales are compressed logarithmically and the interface prints exactly what it did; an orbit whose shape or plane was never solved for is drawn circular and coplanar and says so, and a world the archive places nowhere is named rather than given an orbit.
- The signal each world was found in: A Signal tab reads the transit depth, duration and chord, the star's velocity wobble, reflex orbit and astrometric shift, the odds the orbit transits at all, and the dip one atmospheric scale height would add. It draws them as the limb-darkened light curve and velocity curve an instrument records, with the floor pinned to the archive's measured depth.
- Next transit: From the archive's ephemeris, the next crossing in the reader's own time zone, converted from barycentric time to an Earth clock, with an error that grows by the period uncertainty every orbit. A prediction whose error rivals the transit itself is flagged.
- Composition: The implied iron-core fraction from Zeng et al. (2016) over the 1–8 Earth-mass range the fit covers, the Earth Similarity Index as the comparison it is, and a mass–radius diagram against the Solar System's planets and the rocky composition curves. Minimum masses carry an arrow and estimated radii are drawn hollow.
- Exoplanet atlas: Every confirmed planet on the period–radius and period–mass planes, with the radius valley and the Solar System marked, and discoveries stacked by year and method. Estimated values stay off their axis unless asked for, hovering identifies a world and clicking travels there.
- Where to look: The constellation from the IAU boundaries (Roman 1987), the season it crosses the meridian at midnight, the latitudes that never see it rise or set, the instrument its magnitude needs, and how long Voyager 1 would take to get there.
- Hertzsprung–Russell diagram: Each star placed from its own measured temperature, magnitude and distance among the naked-eye stars Exora already ships for its sky.
- Diorama clock and sonification: Hold, reverse or speed up a system's orbits, and listen to them. Each world plucks a note pitched to its orbital frequency as it crosses Earth's line of sight, so a resonant chain like TRAPPIST-1's is heard as rhythm, and each neighbouring pair's period ratio is named.
- One interface for every destination, lit by its star: A world, a star, a black hole, a diorama and a region all use the same layout: actions across the top, the destination's name and the places it leads to at the lower left, and four headline readings in a compact inspector on the right. Explanations and detailed readings open with Show details. On a phone the name and readings share one bottom sheet that rests at a peek and is dragged, flicked or tapped open. Expanded sheets scroll, and a fully opened sheet pauses the scene until closed. The interface's single accent colour is the blackbody colour of the star lighting the destination, so a red dwarf's world reads warm and Sirius reads cold.
- Go anywhere: The search field in the top bar, ⌘K, Ctrl+K or
/opens a palette ranking every bundled world, moon, region, black hole and tour instantly, with planets, host systems and stars from the archives merged in as you type. Planet search matches names whatever separates their parts. - Guided tours: Narrated journeys through real destinations, each step an ordinary shareable URL, started from Explore or the palette.
- Offline: A service worker keeps the shell, hashed assets (including the planet catalog snapshot, so planet search and browse keep working), textures and sky catalogue after the first visit, and serves the last good archive answer only when the network fails.
- Stellar catalog: Resolve stars by exact identifier through SIMBAD's keyless TAP service, with twelve stellar collections spanning nearby stars, solar analogs, blue giants, binaries, variables, and stellar remnants.
- Deterministic world recipes: A shared, versioned
worldgenpackage maps an object's physical properties to a visual class, palette family, terrain, cloud, and ring recipe. The same catalog row always produces the same world, andWORLDGEN_VERSIONinvalidates persisted recipes when the rules change. - Physically motivated renderers: Procedural gas-giant bands and storms, methane-hazed ice giants with ring systems, and displaced rocky terrain with oceans, ice caps, and lava driven by inferred chemistry.
- Surface vistas built from geology: Standing on a world means standing in its landform provinces — crater-saturated highlands, dune seas, canyon systems, lava fields, fractured ice, folded ranges — mixed per world and blended across a patch that runs to a real horizon. For a Solar System body the provinces, palette, crater density, relief and sky are stated from mission science rather than inferred: Io has no impact craters because nothing on it survives long enough to keep one, Venus has almost none, Europa's relief is a fraction of a rocky planet's, and the Moon's sky is black at noon. Sunlight is baked per vertex into the terrain and everything standing on it, so a low sun throws real shadows, and each world is exposed the way a camera would expose for it.
- One resolved star implementation: Photosphere with multi-scale convection, a supergranular magnetic network, limb-brightened faculae, deterministic starspots, limb darkening, corona, and glare — shared by the star scene and by every host star hanging in a planet's sky.
- World Forge: A seeded, reproducible builder for procedural planets, custom stars, and black holes, using the same recipe engines as the catalogs.
- Persistent immersive session: The engine, scene, camera, and WebXR session outlive the active destination, so entering and leaving AR does not rebuild the viewing context.
- iPhone and Android AR: The immersive control selects native
immersive-aron a phone that supports it and uses Variant Launch's App Clip handoff on iPhone. AR presents the existing Babylon world at tabletop scale over camera passthrough, with hit-tested placement, drag repositioning, and pinch scaling — no GLB or USDZ export path. - Adaptive rendering budget: Separate desktop and mobile profiles govern shader octaves, sphere tessellation, star count, texture detail, and render scale.
- Graceful degradation: A committed planet catalog snapshot, bundled into the API and loaded by the browser, answers planet search, browse and exact-name reads without reaching NASA, and the browser uses the API until it has loaded. A twelve-hour star cache and a bundled local profile keep the experience alive when SIMBAD or the API is unreachable.
| Key | Does |
|---|---|
⌘K, Ctrl+K or / |
Go anywhere by name |
| Backspace | Open or close Explore |
| H | Hide or show the interface |
| Esc | Close whatever is open |
| Drag, scroll, W A S D | Orbit, zoom or land, and walk on a surface |
| Tab | Move between controls; it is never intercepted |
- Toolchain and monorepo: Vite+ (
vp), pnpm workspaces with a version catalog, TypeScript - Web: React 19 with React DOM, Vite, Babylon.js 9 (WebGL2 + WebXR)
- API: Hono, run as a Cloudflare Worker or on Node 26
- Data sources: NASA Exoplanet Archive TAP, SIMBAD TAP (CDS, Strasbourg), and NASA/JPL APIs
- Hosting: One Cloudflare Worker with static assets; the same build also runs from a single Node process
Install the workspace and run the static checks and tests with Vite+:
vp install
vp check
vp testThe website and API are separate workspace applications with their own commands, so they run through Vite Task:
vp run dev # website and API together
vp run dev:web # website only
vp run dev:api # API only
vp run ready # check, test, and build every workspacePull requests and pushes to main run formatting, linting, type checking, unit tests, asset
provenance checks, production builds (including the JavaScript performance budget), production
dependency auditing, and the desktop Chromium journey smoke suite. Install Chromium once, then run
the browser suites locally with:
vp exec --filter website -- playwright install chromium
vp run website#test:browser:desktop
vp run website#test:browser:fullThe full command adds the mobile Chromium configuration. Nightly and manual GitHub Actions runs also execute both Lighthouse profiles. See Performance quality gates for the local Lighthouse command and manual workflow instructions.
The site serves on http://localhost:5173 and the API on http://localhost:8787:
GET /api/health
GET /api/planets?q=kepler&limit=12
GET /api/planets?category=ocean-candidates&limit=12
GET /api/planets?host=GJ%20674
GET /api/planets/featured
GET /api/planets/population
GET /api/planets/:name
GET /api/stars?q=sirius&limit=12
GET /api/stars?category=nearby-stars&limit=12
GET /api/stars/featured
GET /api/stars/:name
vp dev and vp build always invoke Vite's built-in commands. Use vp run dev in this repository so both applications start.
Local development uses the website's Vite server and its /api proxy and needs no Cloudflare
tooling. The API build emits the Node server and the Worker, and checks that the Worker loads no
Node.js built-in and fits the Workers Free size limit:
vp run @exora/api#build
vp run deploy:check # bundle the Worker and static assets with wrangler, without publishingWebXR requires a secure context. Localhost works for desktop development, but testing from a phone on the local network needs HTTPS or a deployed origin.
Follow the iPhone AR deployment and smoke-test guide for Variant Launch configuration and real-device testing.
apps/api Hono API with NASA, SIMBAD, and JPL adapters
apps/website React interface and the Babylon.js scene host
packages/contracts Shared API, exoplanet, and star types
packages/planet-catalog Committed NASA catalog snapshot and the planet search code both apps share
packages/worldgen Deterministic data-to-world recipe engine
Inside apps/website/src, the interface is built from a small set of parts:
styles/tokens.css Colour, type scale, spacing, radii and motion; nothing else defines them
star-light.ts The local star's light, which every destination sets as the accent
readable.ts Sentence case for readings still produced in capitals
components/ui Button, Icon, TabBar, Segmented, Kbd, Spinner
components/shell The chrome every destination shares: top bar, sheet, loading, hints
components/catalog Search, collection rail and result cards shared by every catalog
A destination describes itself to DestinationShell through an identity and a DestinationPanelModel
and never lays out chrome of its own; Explore's catalogs are assembled from components/catalog.
packages/worldgen is the only place a catalog row becomes an appearance, so the API and browser renderer describe an object the same way. Texture provenance and licensing for the close-range detail maps are recorded in THIRD_PARTY_ASSETS.md.
Production is one Cloudflare Worker with static assets, described by the committed
wrangler.jsonc. The Deploy workflow builds and publishes main once the CLOUDFLARE_API_TOKEN
and CLOUDFLARE_ACCOUNT_ID repository secrets exist, and reads VITE_VARIANT_LAUNCH_KEY from the
repository variable of that name. The repository does not install wrangler; a manual deployment
uses the latest release as a one-off command:
vp run deploy # build every workspace, then pnpm dlx wrangler@latest deployThe website builds to static files that Cloudflare serves directly. Only /api/* starts the
Worker, so pages, bundles, textures and the sky catalogue are static-asset requests, which
Cloudflare does not bill as Worker invocations and which never use Worker CPU. Response headers
and cache rules for those files live in apps/website/public/_headers, the /favicon.svg alias in
_redirects, and an unknown path answers 404 with 404.html.
Inside the Worker, Hono routes every /api/* path. Successful reads are kept in the data centre's
Workers Cache API for as long as their CDN-Cache-Control allows, so a repeated read is one cache
lookup rather than an application run or an archive query. The Worker needs no Node.js
compatibility layer, decodes the bundled planet catalog while the isolate starts, and is about
660 KB gzipped, well inside the Workers Free limit.
Nothing in the build is specific to Cloudflare. _headers and _redirects are also read by
Cloudflare Pages and Netlify, and the Node server hosts the whole site from one process on any VPS,
container or PaaS:
vp run -r build
STATIC_ROOT=apps/website/dist node apps/api/dist/index.mjsIt serves the API first, then the built site with the same headers, rewrites, trailing-slash
redirects and 404 page. Behind a reverse proxy, set TRUSTED_CLIENT_IP_HEADER to the header the
proxy overwrites with the caller's address so rate limits apply per caller.
The API answers planet search, browse, and exact-name reads from a committed snapshot of the NASA Exoplanet Archive and queries the live archive for everything else. Stars resolve through SIMBAD, which needs no registration or key; Solar System ephemerides resolve through NASA/JPL services.
Planet search, browse pages, and exact-name lookups read packages/planet-catalog/data/pscomppars.json, a snapshot of the confirmed NASA catalog that a weekly workflow regenerates and proposes as a pull request. The API bundles it, so no instance downloads the catalog. The website loads it as a hashed asset in a module worker after the first scene, or when search opens, and then answers those reads off the main thread with the same matching, ranking, pagination, and nullable measurements. Until it loads, or if it fails, the website uses the API. Names and searches the snapshot lacks fall back to cached live TAP queries. Discovery collections and host-system queries keep their live-query caches. Stars use a twelve-hour cache, and all upstream requests have timeouts. Responses also carry CDN-Cache-Control, which the Worker's edge cache honours, so repeated reads never reach the archives.
The in-process caches and request budgets are intentionally per-instance safeguards, not global quotas. See API rate limiting and caching for the trust boundary, shared-cache behavior, operational tradeoffs, and the evidence threshold for adding a globally enforced control.
The browser holds one Babylon engine for the lifetime of the page. Worlds are built into and removed from that single scene, which is what lets travel between destinations reuse one engine and XR session.
flowchart TB
subgraph Clients
Desktop["Desktop browser<br/>orbit controls"]
Phone["iPhone / Android<br/>WebXR AR session"]
end
subgraph Browser["Browser runtime"]
UI["React interface<br/>catalogs + World Forge"]
Host["Scene host<br/>one Babylon engine + XR session"]
Worldgen["@exora/worldgen<br/>deterministic recipes"]
end
subgraph Cloudflare["Cloudflare"]
Static["Static assets<br/>apps/website/dist"]
Fn["API Worker<br/>Hono, /api/*"]
Edge["Workers Cache API<br/>per data centre"]
end
subgraph Archives["Archives"]
NASA["NASA Exoplanet Archive<br/>TAP"]
SIMBAD["SIMBAD<br/>TAP, keyless"]
JPL["NASA/JPL<br/>Horizons"]
end
Desktop --> Static
Phone --> Static
Static --> UI
UI --> Host
Host --> Worldgen
UI -->|/api/planets, /api/stars| Fn
Host -->|/api/ephemerides| Fn
Fn <-->|repeated reads| Edge
Fn --> NASA
Fn --> SIMBAD
Fn --> JPL