36 KiB
Universe Family — Design Spec
Date: 2026-04-25
Status: Draft
Packages: 9 new packages across geo, astronomy, and gaming domains
Replaces: @suaveplan/day-night (retired), @suaveplan/geo (restructured into 3 packages)
1. Motivation
The existing @suaveplan/day-night package mixes gaming concerns (ECS components, gameplay modifiers, in-game clock) with simplified pseudo-astronomy (hardcoded sinusoidal sun arc, 30-day moon cycle, no lat/lon). The existing @suaveplan/geo covers Earth-surface geometry only. Neither is enterprise-grade, and neither is extensible.
This spec defines a nine-package family that:
- Provides real, observatory-accurate astronomical calculations via a pluggable provider architecture
- Generalises Earth-surface geometry to any planet via configurable ellipsoid + physical parameters
- Supports an observer-centric model: stars, planets, tides, and lighting are computed from the point of view of a single observer position — on any planet surface, in atmosphere, or anywhere in space
- Runs all of the above at configurable N× speed for game engines, with physically-derived lighting and gameplay modifiers replacing hardcoded presets
- Locks in a binary wire protocol (BitSchemas via
@suaveplan/bitpacker) for efficient server→client delivery of star query results, deferring the SurrealDB implementation to a later phase
2. Package Family & Dependency Graph
Locations in genesis monorepo
Layer 0 — Abstract geometry
universal/geo/geo @suaveplan/geo
Layer 1 — Concrete surfaces
universal/geo/geo-earth @suaveplan/geo-earth
universal/geo/geo-planet @suaveplan/geo-planet
Layer 2 — Astronomical observer + plugin hub
universal/astronomy/universe @suaveplan/universe
universal/astronomy/solar-system @suaveplan/solar-system
universal/astronomy/stars @suaveplan/stars
universal/primitives/tides @suaveplan/tides (enterprise rewrite)
Layer 3 — Game layer
universal/gaming/gaming-universe @suaveplan/gaming-universe
universal/gaming/gaming-environment-lighting @suaveplan/gaming-environment-lighting
Dependency graph
geo (zero workspace deps)
geo-planet → geo, error, types
geo-earth → geo, geo-planet, error, types ← geo-earth IS geo-planet configured for Earth
universe → geo, geo-planet, bitpacker, error, types
solar-system → universe, error, types
stars → universe, bitpacker, error, types
tides → universe, geo-planet, error, types
gaming-universe → universe, timing, events, error, types
gaming-environment-lighting → gaming-universe, events, error, types
What happens to existing packages
@suaveplan/day-night— retired.gaming-environment-lightingis its enterprise replacement.@suaveplan/geo— restructured. The enterprise migration of the existing package becomesgeo-earth. Abstract math is extracted intogeo.geo-planetis new.@suaveplan/tides— enterprise rewrite in place; gainsTidalProvideradapter foruniverseintegration.
3. @suaveplan/geo — Abstract Geometry
Purpose
Pure spherical and ellipsoidal geometry math. No planet-specific constants, no astronomical meaning. Every operation that needs a body's shape takes an explicit Ellipsoid parameter.
Dependencies
@suaveplan/error, @suaveplan/types, zod, @opentelemetry/api. Zero other workspace deps.
Core types
interface Ellipsoid {
semiMajorAxis: number; // equatorial radius, metres
semiMinorAxis: number; // polar radius, metres
flattening: number; // derived: (a − b) / a
eccentricity: number; // derived
}
interface SphericalCoord {
latitude: number; // degrees, −90 to 90
longitude: number; // degrees, −180 to 180
altitude?: number; // metres above surface
}
interface Vec3 { x: number; y: number; z: number }
interface Mat3x3 { /* 9-element row-major matrix */ }
Subpath exports
| Subpath | Contents |
|---|---|
geo/ellipsoid |
Ellipsoid, defineEllipsoid(a, b) |
geo/transforms |
sphericalToCartesian, cartesianToSpherical, toRadians, toDegrees |
geo/distance |
greatCircleDistance(a, b, ellipsoid, unit?), vincentyDistance(a, b, ellipsoid, unit?) |
geo/bearing |
bearing(from, to), destination(start, bearingDeg, distM, ellipsoid), midpoint(a, b) |
geo/angular |
angularSeparation(a, b) — angle between two unit-sphere points |
geo/bounds |
BoundingBox, createBoundingBox, containsPoint, expandBox, mergeBoxes, intersects |
geo/polygon |
pointInPolygon, polygonArea, polygonPerimeter, simplifyPolygon |
geo/units |
DistanceUnit, convertDistance |
Explicit non-scope
- No numeric constant for any specific body (Earth, Mars, etc.)
- No geocoding providers
- No geohash
- No atmospheric models
- No astronomical meaning — nothing about observers, stars, or coordinate frames
4. @suaveplan/geo-planet — Configurable Planet Surface
Purpose
Defines PlanetConfig — the single source of truth for any planet's physical, rotational, atmospheric, and satellite properties. Implements surface geometry on any configured ellipsoid.
Dependencies
@suaveplan/geo, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
Core types
interface PlanetConfig {
name: string;
mass: number; // kg
radius: number; // metres, equatorial
polarRadius: number; // metres
surfaceGravity: number; // m/s²
rotationPeriod: number; // seconds (sidereal day)
axialTilt: number; // degrees
atmosphere: AtmosphereConfig | null;
satellites: SatelliteConfig[];
orbital?: OrbitalConfig; // Keplerian elements — for game-defined planets
textures?: PlanetTextureManifest; // identifier refs only, no bundled assets
}
interface AtmosphereConfig {
surfacePressure: number; // Pa
scaleHeight: number; // metres (pressure halving altitude)
rayleighCoefficient: number; // for sky colour computation
mieCoefficient: number; // for Mie scattering (dust, haze)
extinctionPerAirMass: number; // magnitudes of star dimming per air mass
}
interface SatelliteConfig {
name: string;
mass: number; // kg
radius: number; // metres
orbitalRadius: number; // metres, semi-major axis
orbitalPeriod: number; // seconds
orbitalInclination: number; // degrees
eccentricity: number;
albedo: number; // 0–1, reflectivity
}
interface OrbitalConfig {
semiMajorAxisAU: number;
eccentricity: number;
inclinationDeg: number;
argumentOfPerihelionDeg: number;
ascendingNodeDeg: number;
meanAnomalyAtEpochDeg: number;
epochJd: number; // Julian date of reference epoch
}
interface PlanetTextureManifest {
diffuse?: string; // URL or asset identifier
normal?: string;
specular?: string;
clouds?: string;
night?: string; // city lights, etc.
}
PlanetSurface
class PlanetSurface {
constructor(planet: PlanetConfig)
distance(a: SphericalCoord, b: SphericalCoord, unit?: DistanceUnit): number
bearing(from: SphericalCoord, to: SphericalCoord): number
destination(start: SphericalCoord, bearingDeg: number, distanceM: number): SphericalCoord
pointInPolygon(point: SphericalCoord, polygon: SphericalCoord[]): boolean
atmosphericDensity(altitudeMetres: number): number // kg/m³, derived from AtmosphereConfig
airMass(altitudeDeg: number): number // Kasten–Young formula
}
5. @suaveplan/geo-earth — Earth Configuration
Purpose
geo-earth is geo-planet pre-configured for Earth. It is not a sibling of geo-planet — it depends on geo-planet and exposes Earth's PlanetConfig as a constant, plus Earth-specific additions that do not generalise to other planets.
Dependencies
@suaveplan/geo, @suaveplan/geo-planet, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
What it provides
// Earth's PlanetConfig constant — WGS84 ellipsoid, Moon as satellite, ISA atmosphere
export const EARTH: PlanetConfig
// Pre-built PlanetSurface for Earth — no config required
export const earthSurface: PlanetSurface
// Earth-specific additions
export { GeocodingProvider, GeocodingResult, createInMemoryProvider, batchGeocode }
export { encodeGeohash, decodeGeohash }
export { parseCoordinate, formatCoordinate } // DMS parsing / formatting
The enterprise migration of the existing @suaveplan/geo lands here. The existing public API is preserved — only the internal implementation changes (math now delegates to geo-planet).
6. @suaveplan/universe — Observer Model, Frames, Plugin Registry, Wire Protocol
Purpose
The semantic core of the family. Where geometry becomes astronomy. Owns: the unified observer position type, coordinate frame definitions and converters, provider interfaces, the plugin registry, and wire BitSchemas.
Dependencies
@suaveplan/geo, @suaveplan/geo-planet, @suaveplan/bitpacker, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
6a — ObserverPosition
type ObserverPosition =
| { type: 'surface'; planet: PlanetConfig; coord: SphericalCoord }
| { type: 'atmospheric'; planet: PlanetConfig; coord: SphericalCoord; altitudeMetres: number }
| { type: 'space'; position: Vec3; frame: CoordinateFrame }
The PlanetConfig is carried inline on surface/atmospheric positions — no lookup required at query time.
6b — CoordinateFrame
type CoordinateFrame =
| { type: 'heliocentric' } // Sun at origin, AU scale, J2000 equatorial
| { type: 'galactic' } // Galactic centre, light-year scale
| { type: 'custom'; origin: Vec3; basisMatrix: Mat3x3 }
// Converters — pure functions, fully testable
function convertFrame(position: Vec3, from: CoordinateFrame, to: CoordinateFrame): Vec3
function toHeliocentric(position: Vec3, from: CoordinateFrame): Vec3
function toGalactic(position: Vec3, from: CoordinateFrame): Vec3
Heliocentric is used within the solar system. Galactic for interstellar travel. Custom allows game-defined local frames.
6c — Provider interfaces
interface CelestialBody {
name: string
type: 'star' | 'planet' | 'moon' | 'asteroid' | 'comet'
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
apparentMagnitude: number
angularDiameterArcmin: number // apparent size in arcminutes
distanceAU: number
heliocentricPosition: Vec3
}
// Raw catalog record — no observer-relative fields
interface CatalogStar {
catalogId: string // e.g. 'HR 7001' (BSC5), 'HIP 91262' (Hipparcos)
ra: number // Right Ascension, degrees J2000
dec: number // Declination, degrees J2000
distance?: number // parsecs (undefined if no parallax data)
apparentMagnitude: number
spectralType: string
}
// Observer-relative star — returned by getVisibleStars; altitude/azimuth always present
interface ObservedStar extends CatalogStar {
altitude: number // degrees above observer horizon
azimuth: number // degrees clockwise from north
extinctedMagnitude: number // apparentMagnitude dimmed by atmosphere (= apparentMagnitude on vacuum/space)
}
// Union used in StarProvider — queryCone returns CatalogStar, getVisibleStars returns ObservedStar
type Star = CatalogStar | ObservedStar
interface StarQueryOptions {
magnitudeLimit?: number
atmosphericExtinction?: boolean
minAltitudeDeg?: number
maxResults?: number
}
interface TideExtreme {
type: 'high' | 'low'
height: number // metres above chart datum
time: Date
satellite?: string // which moon caused this extreme (multi-moon planets)
}
interface SolarProvider {
getBodies(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody[]>
getBody(name: string, observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<CelestialBody>
// ObserverPosition carries PlanetConfig for surface/atmospheric; space observer uses heliocentric frame
getSunrise(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
getSunset(observer: ObserverPosition, date: Date, signal?: AbortSignal): Promise<Date>
}
interface StarProvider {
// Returns ObservedStar[] — altitude/azimuth always populated
getVisibleStars(observer: ObserverPosition, time: Date, options: StarQueryOptions, signal?: AbortSignal): Promise<ObservedStar[]>
// Returns CatalogStar[] — catalog-space query, no altitude/azimuth
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<CatalogStar[]>
getStar(catalogId: string, signal?: AbortSignal): Promise<CatalogStar>
}
interface TidalProvider {
predictHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date, signal?: AbortSignal): Promise<number>
getExtremes(planet: PlanetConfig, coord: SphericalCoord, date: Date, signal?: AbortSignal): Promise<TideExtreme[]>
}
// WeatherProvider — locked now, implementation deferred (@suaveplan/weather)
// Modulates star visibility (cloud cover occludes), sky colour (Mie scatter from fog/dust),
// and gameplay modifier visibilityRangeMetres. UniverseClient passes weather state to
// gaming-environment-lighting without breaking changes when @suaveplan/weather lands.
interface WeatherState {
cloudCoverage: number // 0–1 (0 = clear, 1 = overcast)
cloudOpticalDepth: number // effective magnitude extinction from clouds (0 = none)
fogDensity: number // 0–1, drives Mie coefficient boost in sky colour
precipitationRate: number // mm/hr, for surface wetness / visibility degradation
}
interface WeatherProvider {
getWeather(observer: ObserverPosition, time: Date, signal?: AbortSignal): Promise<WeatherState>
}
6d — UniverseClient
interface SceneOptions {
includeSolar?: boolean // default true — compute sun/moon/planet positions
includeStars?: boolean // default true — query visible stars
includeTides?: boolean // default true — compute tidal extremes (surface only)
starOptions?: StarQueryOptions // passed through to StarProvider.getVisibleStars
}
class UniverseClient {
register(domain: 'solar', provider: SolarProvider): this
register(domain: 'stars', provider: StarProvider): this
register(domain: 'tides', provider: TidalProvider): this
// weather slot: locked now, implementation deferred — gaming-environment-lighting
// reads WeatherState from UniverseScene without a breaking API change when @suaveplan/weather lands
register(domain: 'weather', provider: WeatherProvider): this
// signal cancels all in-flight provider calls
getScene(observer: ObserverPosition, time: Date, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
}
interface UniverseScene {
bodies: CelestialBody[]
stars: ObservedStar[]
tides?: TideExtreme[]
weather?: WeatherState // undefined until WeatherProvider registered
}
6e — Wire BitSchemas
Star data is quantised before packing — rendering does not require float64 precision.
// ~11 bytes per star vs ~200 bytes JSON — ~18× compression
const StarPacket = defineBitSchema([
{ name: 'ra', type: 'uint', bits: 19 }, // 0–360,000 (0.001° steps)
{ name: 'dec', type: 'int', bits: 18 }, // ±90,000
{ name: 'magnitude', type: 'uint', bits: 11 }, // 0–1,200 (×10 + 20 offset: −2.0 to 10.0)
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000
{ name: 'spectralClass', type: 'uint', bits: 4 }, // OBAFGKM + 8 reserved
]);
// CelestialBodyPacket — apparentMagnitude: uint, ×10 + 300 offset, 0.1 magnitude resolution
// covers −30.0 to +174.7 (Sun = −26.7 → stored as 33; Jupiter = −2.9 → stored as 271; full 11-bit range used)
const CelestialBodyPacket = defineBitSchema([
{ name: 'altitude', type: 'int', bits: 15 }, // ±9,000 (0.01° steps)
{ name: 'azimuth', type: 'uint', bits: 16 }, // 0–36,000 (0.01° steps)
{ name: 'angularDiameterArcmin', type: 'uint', bits: 14 }, // 0–10,000 (0.01′ steps, covers up to 166°)
{ name: 'apparentMagnitude', type: 'uint', bits: 11 }, // 0–2,047 (×10 + 300 offset: −30.0 to +74.7)
{ name: 'bodyType', type: 'uint', bits: 3 }, // 0=star 1=planet 2=moon 3=asteroid 4=comet
]);
// PackedObservedStar — only the fields recoverable from StarPacket bits
// catalogId and distance are NOT in the wire format; use the catalog source to re-fetch if needed
interface PackedObservedStar {
ra: number; dec: number; apparentMagnitude: number
altitude: number; azimuth: number
spectralClass: number // 0-6 = OBAFGKM, 7 = unknown
}
// Pack/unpack helpers — consumers never touch raw bit widths
// packStars requires ObservedStar (altitude/azimuth always present)
function packStars(stars: ObservedStar[]): Uint8Array
function unpackStars(buf: Uint8Array, count: number): PackedObservedStar[]
// PackedCelestialBody — only the fields recoverable from CelestialBodyPacket bits
// name, distanceAU, heliocentricPosition are NOT in the wire format
interface PackedCelestialBody {
altitude: number; azimuth: number
angularDiameterArcmin: number; apparentMagnitude: number
bodyType: 0 | 1 | 2 | 3 | 4 // 0=star 1=planet 2=moon 3=asteroid 4=comet
}
function packBodies(bodies: CelestialBody[]): Uint8Array
function unpackBodies(buf: Uint8Array, count: number): PackedCelestialBody[]
7. @suaveplan/solar-system
Purpose
Implements SolarProvider using astronomy.js for real solar system bodies, and a Keplerian propagator for game-defined planets carrying OrbitalConfig.
Dependencies
@suaveplan/universe, @suaveplan/error, @suaveplan/types, astronomy-engine, zod, @opentelemetry/api.
Two-engine model
Real bodies (Sun, Moon, Mercury–Neptune, Pluto, Ceres): astronomy.js computes positions, rise/set/transit, phase, illumination, angular diameter — correct to arcsecond precision.
Configured bodies (game planets with OrbitalConfig): Keplerian solver propagates position from epoch. Physically plausible, not observatory-grade.
Per CelestialBody computed from ObserverPosition:
| Field | Source |
|---|---|
altitude / azimuth |
Observer lat/lon + time → horizontal coordinates |
angularDiameterArcmin |
2 × arctan(radius / distance) × (180/π × 60) |
apparentMagnitude |
Phase angle + distance (astronomy.js for real bodies) |
heliocentricPosition |
Vec3 in heliocentric frame |
For SurfacePosition / AtmosphericPosition observers, PlanetConfig.atmosphere applies extinction to magnitudes near the horizon.
Additional exports (standalone, outside SolarProvider)
// planet defaults to EARTH — pass any PlanetConfig with axialTilt and OrbitalConfig for other planets
getSeason(date: Date, planet?: PlanetConfig): Season
getNextEquinox(after: Date, planet?: PlanetConfig): Date
getNextSolstice(after: Date, planet?: PlanetConfig): Date
getLunarPhase(time: Date): LunarPhase // phase 0–1, illumination %, name (Earth/Moon only)
predictEclipse(after: Date, type: 'solar' | 'lunar'): EclipseEvent // Earth only
getConstellation(ra: number, dec: number): string
All functions return Date in UTC. Consumers needing timezone-localised sunrise/sunset/season dates should convert via @suaveplan/datetime, which handles tz-aware date math and is the monorepo-standard for timezone operations.
Injectable engine
interface AstronomyEngine {
bodyPosition(body: string, observer: AstroObserver, time: AstroTime): EquatorialCoords
riseSetTransit(body: string, observer: AstroObserver, date: AstroTime): RiseSetTransit
}
function createSolarSystemProvider(options?: { engine?: AstronomyEngine }): SolarProvider
Tests inject a deterministic engine. Production uses astronomy.js.
8. @suaveplan/stars
Purpose
Implements StarProvider with a two-level plugin system: swappable catalog sources within stars, and stars itself registered as a plugin to universe.
Dependencies
@suaveplan/universe, @suaveplan/bitpacker, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
Core types
// Native catalog record — the shape every StarCatalogSource works with internally
interface RawStar {
catalogId: string
ra: number // degrees J2000
dec: number // degrees J2000
apparentMagnitude: number
spectralType: string
distance?: number // parsecs
properMotionRa?: number // mas/yr — present in Hipparcos and above
properMotionDec?: number // mas/yr
parallax?: number // mas — present in Hipparcos and above
}
Catalog source interface
interface StarCatalogSource {
readonly id: string
readonly magnitudeLimit: number
readonly starCount: number
queryCone(ra: number, dec: number, radiusDeg: number, options?: StarQueryOptions, signal?: AbortSignal): Promise<RawStar[]>
getById(catalogId: string, signal?: AbortSignal): Promise<RawStar | null>
}
Bundled catalog — BSC5
Yale Bright Star Catalog (~9,096 stars, magnitude ≤ 6.5). Ships as compact binary (bitpacked at build time). ~100 KB vs ~2 MB JSON.
import { createBsc5Source } from '@suaveplan/stars/catalogs/bsc5'
const source = createBsc5Source() // no config, no network, offline
External catalog interfaces (contracts locked in, implementations deferred)
createHipparcosSource(options: HipparcosSourceOptions): StarCatalogSource // 118k stars
createTycho2Source(options: Tycho2SourceOptions): StarCatalogSource // 2.5M stars
createGaiaSource(options: GaiaSourceOptions): StarCatalogSource // 1.5B sources
Each takes injectable fetchFn (wrap with @suaveplan/retry's circuit breaker + jittered backoff — Hipparcos/Tycho/Gaia are flaky external APIs) and optional cacheFn (wire @suaveplan/cache's get/set — catalog queries are read-mostly and TTL-friendly).
SurrealDB provider interface (locked in, implementation deferred)
SurrealAdapter is defined structurally within stars — @suaveplan/surrealdb satisfies it at runtime but is not a hard dependency, keeping stars free of the database package when the SurrealDB source is not used.
// Structural interface — satisfied by SurrealAdapter from @suaveplan/surrealdb
interface SurrealAdapterLike {
query<T>(sql: string, vars?: Record<string, unknown>): Promise<T[]>
}
interface SurrealStarProviderOptions {
adapter: SurrealAdapterLike
namespace: string
database: string
coneFn: string // SurrealDB JS function name, default: 'stars::cone_search'
extinctionFn: string // default: 'stars::apply_extinction'
atmosphereDensity?: number // g/cm², derived from observer's PlanetConfig.atmosphere
}
function createSurrealStarSource(options: SurrealStarProviderOptions): StarCatalogSource
Results arrive bitpacked (StarPacket), unpacked client-side using unpackStars().
Atmospheric extinction
// Pogson's formula — applied when ObserverPosition has AtmosphereConfig
extinctedMagnitude = rawMagnitude + extinctionCoeff × airMass(altitudeDeg)
// Kasten–Young formula — accurate to ~5° altitude
function airMass(altitudeDeg: number): number
SpacePosition and vacuum-planet observers receive raw magnitudes with no extinction.
StarProvider implementation
class StarProviderImpl implements StarProvider {
constructor(sources: StarCatalogSource[]) // priority-ordered
getVisibleStars(observer, time, options, signal?): Promise<ObservedStar[]>
// 1. Compute horizon frame from ObserverPosition
// 2. queryCone full visible hemisphere
// 3. Filter by minAltitudeDeg, magnitudeLimit
// 4. Apply atmospheric extinction from PlanetConfig
// 5. Sort by brightness
queryCone(ra, dec, radiusDeg, options?, signal?): Promise<CatalogStar[]>
getStar(catalogId, signal?): Promise<CatalogStar>
}
9. @suaveplan/tides — Enterprise Rewrite
Purpose
Enterprise-grade tidal prediction for Earth (via real harmonic data) and any planet (via physics-based model). Implements TidalProvider for universe integration.
Dependencies
@suaveplan/universe, @suaveplan/geo-planet, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
What's preserved
TICON and ADMIRALTY providers are kept intact — same harmonic constituent engine, same station data, same multi-provider fallback chain. Earth API is unchanged.
Enterprise additions (cross-cutting)
- Zod schemas for all public option types and wire formats
AbortSignalon every async methodTidesErrorextending@suaveplan/errorwith stableTIDES.*codes- OTel spans, counters, histograms on every provider call
- Property-based tests (fast-check) on harmonic prediction engine
- Injectable
fetchFn(ADMIRALTY is a flaky external API — wrap with@suaveplan/retrycircuit breaker + jitter),clockFn, optionalcacheFn(wire@suaveplan/cache— tide tables are read-mostly and TTL-friendly)
Multi-satellite physics model
// Tidal height from all satellites in PlanetConfig, superposed
function tidalHeight(planet: PlanetConfig, coord: SphericalCoord, time: Date): number
// Individual satellite contribution — useful for visualisation
function satelliteTidalContribution(
planet: PlanetConfig,
satellite: SatelliteConfig,
coord: SphericalCoord,
time: Date
): number
Physics: tidalRange ∝ (satelliteMass / orbitalRadius³) × (planetRadius⁴ / planetMass). Multiple satellites superpose — orbital resonances produce complex spring/neap interference patterns automatically.
createPhysicsBasedProvider(): TidalProvider // any PlanetConfig with satellites
createTiconProvider(): TidalProvider // Earth, offline harmonic data
createAdmiraltyProvider(options): TidalProvider // Earth, UKHO API 600+ stations
TidalProvider routing
class TidalProviderImpl implements TidalProvider {
constructor(options: {
earthProviders: TidalProvider[] // tried in order for Earth
physicsProvider: TidalProvider // fallback for non-Earth planets
})
predictHeight(planet, coord, time, signal?): Promise<number>
getExtremes(planet, coord, date, signal?): Promise<TideExtreme[]>
}
Earth identification uses planet.name.toLowerCase() === 'earth' — a named string match, not a floating-point mass/radius comparison, which would be fragile. The EARTH constant exported from @suaveplan/geo-earth has name: 'Earth'. Game-defined planets must use a different name. Non-Earth → physics model. Planets with no satellites → zero height, empty extremes.
10. @suaveplan/gaming-universe
Purpose
Wraps UniverseClient with a configurable N× game clock. All queries use game time. The startTime being a real calendar date gives the real historical or projected sky.
Dependencies
@suaveplan/universe, @suaveplan/timing, @suaveplan/events, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
Clock design
GamingUniverse does not invent its own scheduler. It wraps @suaveplan/timing's AsyncScheduler (which already provides AbortSignal drain, interval tick, and injectable clock) to drive game time. setTimeScale/pause/resume control the multiplier applied to timing's real-time ticks before accumulating into game time. This makes cancellation and clock injection uniform with the rest of the monorepo, and makes @suaveplan/game-loop (when it exists) the natural per-tick integration point for calling getScene().
API
interface GamingUniverseOptions {
startTime: Date // real calendar date — determines actual star/planet positions
timeScale: number // initial multiplier: 1 = real, 3600 = 1s = 1hr game time
universe: UniverseClient
clock?: Clock // injectable — from @suaveplan/types; defaults to Date.now
}
class GamingUniverse {
constructor(options: GamingUniverseOptions)
setTimeScale(n: number): void
setGameTime(date: Date): void
pause(): void
resume(): void
isPaused(): boolean
getGameTime(): Date
// Full scene at current game time — signal cancels in-flight provider calls
getScene(observer: ObserverPosition, options?: SceneOptions, signal?: AbortSignal): Promise<UniverseScene>
readonly universe: UniverseClient
// Stops the clock and cancels all in-flight queries; idempotent
dispose(): void
}
Events
// All emitted via @suaveplan/events
hourChanged { gameTime: Date }
dayChanged { gameTime: Date; dayNumber: number }
seasonChanged { season: 'spring' | 'summer' | 'autumn' | 'winter' }
sunriseStarted { planet: PlanetConfig; coord: SphericalCoord }
sunsetStarted { planet: PlanetConfig; coord: SphericalCoord }
moonriseStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing
moonsetStarted { satellite: SatelliteConfig; altitude: number } // altitude at horizon crossing (typically ≈0°)
eclipseStarted { type: 'solar' | 'lunar'; totality: number }
11. @suaveplan/gaming-environment-lighting
Purpose
Replaces @suaveplan/day-night. Light schedule, sky colour, star visibility thresholds, and gameplay modifiers are all physically derived from astronomical state — no hardcoded presets.
Dependencies
@suaveplan/gaming-universe, @suaveplan/events, @suaveplan/error, @suaveplan/types, zod, @opentelemetry/api.
Sky colour — Rayleigh/Mie scattering
sunAltitude → airMass → scatteringPath → RGB sky colour
Driven by AtmosphereConfig.rayleighCoefficient and mieCoefficient:
- Earth noon: deep blue
- Earth sunset: orange/red (long scatter path)
- Mars noon: butterscotch (CO₂ + dust Mie scatter)
- Vacuum world: black always, stars always visible
Twilight star visibility thresholds
sun altitude > 0° no stars visible
sun altitude ≤ 0° civil twilight magnitude ≤ 1
sun altitude ≤ −6° nautical twilight magnitude ≤ 3
sun altitude ≤ −12° astronomical twilight magnitude ≤ 6
sun altitude ≤ −18° full dark all naked-eye stars
On vacuum planets (no AtmosphereConfig): threshold collapses, stars always visible.
Satellite lighting
// Per satellite contribution, summed across all PlanetConfig.satellites
moonLight ∝ phaseIllumination × angularDiameter² × albedo
Multiple moons sum. Large close moons can brightly illuminate the surface at "night".
Gameplay modifiers — continuous functions of ambient light
interface EnvironmentModifiers {
totalAmbientLight: number // 0–1, sun + all moons
stealthBonus: number // 1 − totalAmbientLight
visibilityRangeMetres: number // base × totalAmbientLight × atmosphereClarity
npcSleepProbability: number // sigmoid of localHour relative to sunrise/sunset
}
Environment component shapes
These are plain TypeScript interfaces — framework-agnostic data shapes. They are not registered as ECS archetypes and carry no dependency on @suaveplan/ecs. Consumers wiring them into an ECS (e.g. bitecs, miniplex) do so by reading these shapes and mapping fields to their own component storage.
interface DayNightClock { gameTime: Date; timeScale: number; paused: boolean }
interface LightState { skyColour: RGB; sunAltitude: number; totalAmbientLight: number;
moonContributions: MoonLight[]; weather?: WeatherState }
interface DayNightAffected { modifiers: EnvironmentModifiers }
Render payload
gaming-environment-lighting publishes a RenderPayload each tick — the canonical shape for downstream render integrations (@suaveplan/r3f-environment, Three.js custom shaders, etc.). Locking this now prevents churn when R3F integration lands.
interface RenderPayload {
// Sky
skyColour: RGB
sunAltitude: number // degrees
sunAzimuth: number // degrees
horizonColour: RGB // for gradient blending at low sun angles
// Lighting
totalAmbientLight: number // 0–1
moonContributions: MoonLight[]
// Atmosphere
fogDensity: number // from AtmosphereConfig + WeatherState
rayleighTint: RGB // for shader uniforms
// Textures (from PlanetTextureManifest — identifiers only, no assets)
textures: PlanetTextureManifest
// Weather (undefined if no WeatherProvider registered)
weather?: WeatherState
}
gaming-environment-lighting never loads or stores texture assets — PlanetTextureManifest identifiers are passed through verbatim.
12. Implementation Order
Packages must land in dependency order:
1. geo
2. geo-planet
3. geo-earth (depends on geo-planet)
4. universe (depends on geo, geo-planet)
5. solar-system (depends on universe)
6. stars (depends on universe)
7. tides (depends on universe, geo-planet)
8. gaming-universe (depends on universe)
9. gaming-environment-lighting (depends on gaming-universe)
Each package ships at 0.1.0 per genesis convention, with full 100% test coverage gate before publish.
13. Deferred (Out of Scope for This Spec)
- SurrealDB star storage schema and SurrealQL JS function bodies (
stars::cone_search,stars::apply_extinction) - Hipparcos, Tycho-2, Gaia catalog source implementations
- R3F / Three.js rendering integration for sky, terrain, planet textures
- Multiplayer synchronisation of game time across clients
@suaveplan/weatherintegration with atmospheric lighting
The SurrealDB provider interface (SurrealStarProviderOptions, createSurrealStarSource) is locked in this spec — any future implementation must satisfy it.
14. Cross-Cutting Conventions
All packages in this family follow the monorepo standard:
- Logging:
@suaveplan/logger+@opentelemetry/apitogether — OTel spans carry trace IDs; logger emits structured entries correlated to those spans. Neither is used without the other at public entrypoints. - Retries:
@suaveplan/retry(circuit breaker + jittered backoff) wraps allfetchFnparameters that hit external APIs (ADMIRALTY, Hipparcos, Tycho, Gaia). - Caching:
@suaveplan/cacheis the canonicalcacheFnadapter for read-mostly data (star catalog queries, tide tables). Consumers wire it in; packages don't import it directly. - Dates: All public APIs return
Date(UTC). Consumers needing timezone-aware values use@suaveplan/datetime. - ECS: Component shapes in
gaming-environment-lightingare plain TypeScript interfaces — no@suaveplan/ecsdependency. ECS consumers map fields to their own component storage.
15. Acceptance Criteria
Each package is complete when:
bunx turbo lint typecheck test --filter=@suaveplan/<pkg>greenbunx turbo test:coverage --filter=@suaveplan/<pkg>at 100%- Every exported symbol has a co-located
.md - No
TODO,FIXME,any,ts-ignore, or.skipinsrc/ - Zod schemas for all public option types
@suaveplan/logger+ OTel spans + counters on all public entrypointsAbortSignalon all async methods- OpenSpec change proposal created and archived on ship