Web Client

Overview

Browser-based game client providing 3D visualization and player controls.

Technology

  • 3D Rendering: Three.js
  • Rationale: Lightweight, extensive documentation, large ecosystem, flexible for multi-scale rendering (ship to solar system)

Module Architecture

The cockpit view is decomposed into a thin orchestrator (cockpitView.js) and pure-logic modules that are fully unit-testable with no DOM or Three.js dependencies:

Module Responsibility
instrumentCalc.js Quaternion SLERP, vec3 LERP, interpolation timing, velocity/angular velocity color gradients and vector lengths
controlInput.js Thrust stepping (±10% clamped), attitude key mapping table, docked-key filtering
hudCalc.js ETA formatting, target metrics (distance/closing speed/relative velocity), station proximity search, unit direction vectors, off-screen indicator edge positioning
mathUtils.js Shared 3D math — distance3D(a, b) for {x,y,z} points. Imported by all modules needing distance calculations
targetCalc.js Sorted target list building, target cycle index calculation (imports distance3D from mathUtils.js)
bodyConfig.js getBodyColor() — body subtype to hex color mapping (added to existing config module)

Additional view modules follow the same pattern:

Module Responsibility
orbital.js Pure orbital computation — reference body selection, orbital elements, path/marker generation, Hill sphere, perturbation ratio, SOI encounters, formatting (speed/altitude/period/time), quaternion rotation (quatRotate/quatRotateInverse), gravitational acceleration (gravAccel)
bodyConfig.js Static body metadata — parent hierarchy (BODY_PARENTS), spin axes, spawn altitudes, visual subtypes, color palettes (HUD_COLORS, INDICATOR_COLORS), getBodyColor()/getBodyIndicatorColor() (both delegate to shared _resolveBodyColor), distance formatting
orbitDiagramCalc.js Time-to-anomaly, SVG arc generation, orbit position at true anomaly, SOI/impact crossing detection, orbit scale fitting, hyperbolic path generation, angle normalization, 3D plane projection
shipSystemsCalc.js Fuel/thrust gauge state, delta-v (Tsiolkovsky), burn time from fuel params (formatRemainingBurnTime), acceleration, TWR, rotation rate bars, wheel saturation bars, navball pitch/roll, marker projection, orbital direction vectors (prograde/normal/radial), local vertical, fuel capacity formatting, closing rate formatting
shipSpecsData.js Ship class constants (SHIP_SPECS), max-value computation, SI number formatting, performance metrics (thrust/accel/mass-flow/fuel/burn-time/delta-v/TWR/inertia), compact duration formatting (formatDurationCompact)

All extracted functions are pure (parameters in, values out). View files remain the SVG/DOM orchestrators, calling pure functions and applying results to elements. Methods like _getVelocityColor() become thin wrappers that convert pure {r,g,b} results to THREE.Color objects.

Three.js View Modules

The cockpit view is further decomposed into Three.js-dependent modules that handle geometry creation, overlays, and UI orchestration. Each exports createXxx()/updateXxx()/disposeXxx() functions that receive context objects (scene, camera, state refs) rather than this:

Module Responsibility
shipMeshFactory.js Pure geometry factories — ship meshes (generic, cargo hauler, fast frigate, long-range explorer), RCS pods, station mesh, jump gate mesh. Takes parameters, returns THREE.Group. No state dependencies.
flightOverlays.js Velocity vector (shaft + cone, color-coded by speed), angular velocity vector (body-frame transformed), orbital path line (Float32 buffer with floating-origin pivot), orbital marker sprites (apoapsis/periapsis/nodes/SOI encounters)
tracers.js Player ship trail (reference-body-relative), other ship trails, trail connector line, trail visibility toggle, main engine plume (thrust-scaled), RCS plume bitmask (24-bit firing pattern), plume flicker animation
indicators.js CSS2DObject indicator creation for bodies, ships, stations, jump gates, and Lagrange points. Distance text updates (throttled to 4 Hz), per-category visibility toggles, reference body highlighting
targetManager.js Target selection UI orchestration — add/remove/toggle targets with visual feedback (indicator highlighting), focus cycling within target set, highlight cursor (Tab-based cycling), server notification, settings persistence
targetOverlays.js Target bracket pool (CSS2DObject per target, role-colored), off-screen chevron indicators, “targeted by” brackets/arrows, view lock (camera tracks focused target with SLERP transition), zoom indicator readout
cockpitWindows.js Floating window init/toggle for settings, about, controls, spawn selector (tree builder with body/station/jumpgate rows), ship class selector modal
targetDashboard.js 3D picture-in-picture target preview (separate WebGLRenderer + camera), distance/velocity/ETA readouts, target list rendering, board button for fleet ships

cockpitView.js remains the orchestrator (~2,500 lines): constructor, init/activate/deactivate/dispose, Three.js + CSS renderer setup, menu bar, keyboard dispatch, processInput, extrapolatePositions, animate loop, and data-feed methods for existing modules (orbitDiagram, shipSystems, shipSpecs).

Map View Modules

The map view follows the same decomposition pattern as the cockpit view. mapView.js is a thin orchestrator that delegates to focused modules via standalone functions receiving the view instance as their first parameter:

Module Responsibility
mapRenderer.js Three.js scene, camera, WebGL renderer, CSS2D renderer, lights, resize handler
mapBodies.js Body sphere mesh creation, texture application, ring geometry (Saturn, Uranus)
mapIndicators.js CSS2D indicator creation/removal for bodies, ships, stations, jump gates, Lagrange points
mapOrbitalPaths.js Orbital path line computation and rendering for all object types
mapInfoPanel.js Info panel rendering for selected body/ship/station/jumpgate with orbital elements
mapSystemBrowser.js System browser tree UI — hierarchical navigation of bodies, ships, stations, gates. Filters bodyData by system_id in non-remote mode so only current-system bodies appear.
mapStateUpdate.js Server state synchronization, Verlet position extrapolation, distance label updates

mapView.js remains the orchestrator (~850 lines): constructor, init/activate/deactivate, selection/targeting, camera/view control (fly-to, view lock), animate loop, marker visibility, de-overlap, and panel wrappers (orbit diagram, ship systems, target dashboard).

Map View Selection Helpers

_selectShip, _selectStation, and _selectJumpgate share identical logic (toggle selection, fly-to with orbital distance, update info panel). They are consolidated into a private _selectEntity(id, type, config) method that receives a config object specifying the entity type, state maps, selected-property name, and optional view-distance calculator.

Orbital Display Constants

orbital.js uses named constants instead of inline magic numbers:

Constant Value Purpose
MEGAMETER_ALTITUDE_THRESHOLD 10000 km threshold for Mm display
PERIOD_MINUTES_THRESHOLD 120 seconds → switch to minutes display
PERIOD_HOURS_THRESHOLD 7200 seconds → switch to hours display
PERIOD_DAYS_THRESHOLD 172800 seconds → switch to days display
INCLINATION_LOWER_BOUND 0.1 degrees below which orbit is equatorial
INCLINATION_UPPER_BOUND 179.9 degrees above which orbit is equatorial

Initial Release

Cockpit Interface

First-person control station experience from the ship’s perspective.

3D View

  • Render Sol system from ship’s point of view
  • Display celestial bodies (Sun, planets, major moons)
  • Camera can rotate independently of ship attitude (look around)
  • Clean view with HUD overlay (no visible cockpit structure)

Visualization Scale

The solar system is rendered with a uniform scale factor for all positions and radii:

Parameter Value Description
Scale factor 1e-7 1 unit = 10,000 km
Earth radius 0.64 units 6,371 km
LEO altitude 0.04 units 400 km
Earth-Sun distance ~14,500 units ~1 AU

Camera behavior:

  • Camera follows player’s ship using OrbitControls
  • On first login, camera positioned close to ship (~0.01 units / 100 km)
  • Camera should be close enough to clearly see ship orientation
  • Mouse drag rotates view around ship
  • Scroll wheel zooms in/out
  • Minimum zoom distance: 0.001 units (10 km)
  • Maximum zoom distance: 100,000 units (1 billion km)
  • Camera damping enabled (dampingFactor: 0.1) for smooth panning and zooming

Camera following during extrapolation:

The camera follows the ship smoothly during client-side extrapolation by moving both the camera position and orbit target together:

// Each render frame (60 FPS):
camera_offset = camera.position - controls.target
controls.target = ship.position
camera.position = ship.position + camera_offset

This approach:

  • Keeps the view stable relative to the ship (no perceived “motion” from camera following)
  • Preserves the user’s viewing angle and distance
  • Allows smooth orbiting/panning without interference

Ship size: Ships are rendered at true scale (1e-7 scene units per meter, matching the visualization scale factor). Use HUD body markers (B key) for visibility at orbital distances.

Indicators (Future)

  • Distant objects marked with indicators:
    • Icon/marker showing direction
    • Label with name and distance
    • Indicators visible regardless of zoom level

Indicator Overlap Handling

When multiple CSS2D indicators project to nearby screen positions, they are de-overlapped using screen-space clustering with callout leader lines.

Algorithm:

  1. Screen projection: Each visible indicator’s 3D position is projected to screen coordinates via Vector3.project(camera). Indicators behind the camera (z > 1) or off-screen are skipped.
  2. Clustering (union-find): Two indicators within CLUSTER_RADIUS (30px) screen distance are merged into the same cluster. O(n²) with n ≤ ~40 is trivial.
  3. Per-cluster stacking: Clusters with >1 member are stacked vertically:
    • Sorted by distance ascending (closest object on top)
    • Centered on the cluster centroid
    • Spaced by STACK_GAP (22px) vertically
    • Each indicator receives a CSS transform: translateY(offset) with a 0.15s ease-out transition
  4. Leader lines: SVG <line> elements connect each offset indicator back to its original screen position. Lines use the indicator’s dot color at 50% opacity, 1px stroke width.
  5. Single indicators: CSS transform is cleared, no leader line drawn.

Rendering layers:

Layer z-index Content
SVG leader-line overlay 9 <svg> element, pointer-events: none
CSS2D indicator overlay 10 CSS2DRenderer DOM element

Leader lines render behind labels so labels remain readable.

SVG line pool: A LinePool object pool manages SVG <line> elements to avoid DOM create/destroy churn each frame. Methods: reset(), addLine(x1, y1, x2, y2, color), flush().

Shared module: indicatorDeOverlap.js exports createSVGOverlay(), LinePool, and deOverlapIndicators() — used by both cockpit view and map view.

Visual example (stacked with leader lines):

   ▲ Station Alpha (450 km)  ─────╮
   ● Luna (1,200 km)         ─╮   │
   ● Earth (384,000 km)       │   │
                               ●   ▲  ← original screen positions

Geometry vs Indicator Threshold

Render as 3D geometry when object subtends > 0.5° of field of view:

render_as_geometry = (distance < radius × 229)
Body Radius Geometry Distance
Sun 696,000 km < 159 million km
Earth 6,371 km < 1.46 million km
Luna 1,737 km < 398,000 km
Small moon (200 km) 200 km < 46,000 km

Objects beyond threshold render as indicator only.

Ship Rendering

Ships use the same 0.5° FOV threshold:

ship_geometry_distance = ship_radius × 229
                       = 10 m × 229
                       = 2,290 m ≈ 2.3 km
Ship Distance < 2.3 km Distance ≥ 2.3 km
Own ship Not rendered (first-person view) Not rendered
Other players 3D geometry Indicator
  • Own ship camera is positioned inside the ship; hull is not visible
  • Other player ships always visible (geometry when close, indicator when far)

Station Rendering

Stations use a procedural composite mesh distinct from ships (no engine bell or plume). Built at meter scale (1 unit = 1 m) then scaled by SVS, following the cargo hauler pattern.

Station mesh components (all dimensions at meter scale, scaled by SVS = 1e-7 at end):

Component Geometry Dimensions Position
Hub (central cylinder) CylinderGeometry r=10, h=30, 16 segments origin
Antenna mast CylinderGeometry r=0.5, h=12 y=21 (top of hub)
Comm dish ConeGeometry (inverted) r=3, h=2 y=28 (mast tip)
Window strip TorusGeometry R=10.2, r=0.4 y=5 (around hub)
Spoke struts (×4) CylinderGeometry r=0.8, h=28 rotated 90° in XZ, connecting hub to ring
Docking port CylinderGeometry r=4, h=3 y=-17 (hub bottom)
Habitat ring (torus) TorusGeometry R=40, tube r=5, 12×32 segments flat (rotation.x = π/2)
Solar panels (×4) BoxGeometry 30 × 0.2 × 8 beyond torus radius
Panel support truss (×4) BoxGeometry 0.6 × 0.6 × 30 under each panel
Hinge joint (×4) SphereGeometry r=1 at strut-panel connection
Nav light SphereGeometry r=1.5 y=30 (antenna tip)

Materials (defined upfront, shared across components):

Name Material Color Properties
hubMat MeshStandardMaterial 0x888899 metalness: 0.7, roughness: 0.3
ringMat MeshStandardMaterial 0xaaaacc metalness: 0.5, roughness: 0.4
panelMat MeshStandardMaterial 0x2244aa metalness: 0.3, roughness: 0.7
frameMat MeshStandardMaterial 0x555555 metalness: 0.8, roughness: 0.3
windowMat MeshBasicMaterial 0x88aaff toneMapped: false
navLightMat MeshBasicMaterial 0xff0000 toneMapped: false

Nav light animation:

  • Named group: 'navLight'
  • Blink pattern: (state.gameTick % 10) < 2 — 2 ticks on, 8 ticks off
  • Distinct from ship nav lights (2 on / 14 off at % 16)
  • Animated in extrapolatePositions() station loop, same pattern as ship nav lights

Station indicator:

Property Value
Dot color Cyan (#00cccc)
Label Station name + distance
Type CSS2DObject (same system as body/ship indicators)

Stations are rendered at true scale only (no exaggerated mode). The same 0.5° FOV geometry/indicator threshold applies using the station’s radius.

Rendering (MVP)

  • Celestial bodies rendered as simple colored spheres
  • Sun: Yellow, emissive (light source)
  • Planets/moons: Solid colors based on type
    • Rocky (Mercury, Venus, Mars, moons): Gray/brown/tan
    • Gas giants (Jupiter, Saturn): Orange/tan bands
    • Ice giants (Uranus, Neptune): Blue/cyan
  • Ships: Class-specific geometry (Fast Frigate: ellipsoid, Cargo Hauler: modular)
    • Own ship: Green
    • Other players: Orange (0.2x scale)
  • Shadows optional

Textures:

Body Texture Source
Earth 8K day map NASA Blue Marble (public domain)
Other bodies Solid color N/A

Earth texture rotates with the planet’s rotation period and displays correct axial tilt.

Lighting:

Cockpit view uses physically accurate solar lighting with a DirectionalLight for shadow support. Map view uses a simplified PointLight (no shadows).

Cockpit view lighting:

Light Type Properties
Ambient AmbientLight Color: #060608 (near-black fill for dark sides)
Sun DirectionalLight Color: #ffffff, intensity: 3.0
  • DirectionalLight simulates parallel starlight (valid at planetary distances)
  • Light source is the system’s primary star (looked up from sharedConfig.SYSTEMS[systemId].star_body), not hardcoded to “Sun”
  • Position and target updated per frame to track reference body’s local system
  • Shadow camera uses orthographic projection scoped to local system (planet + moons)
  • Tone mapping: ACESFilmicToneMapping, exposure: 1.0
  • MeshBasicMaterial objects (starfield, nav lights, engine plume) have toneMapped = false

Shadow system (cockpit view only):

Parameter Value
Shadow map size 2048 × 2048
Shadow map type PCFSoftShadowMap
Shadow bias -0.0005
Shadow normal bias 0.02
Default state Always on
Body cast/receive All non-star bodies (including Saturn’s rings)

Shadow frustum sizing:

  • Centered on shadow center body for maximum shadow map resolution
  • Shadow center body: If the reference body is a moon (parent is not a star type in BODY_PARENTS), use the parent planet; otherwise use the reference body itself. This ensures ring shadows (e.g., Saturn’s rings) remain visible when orbiting a moon.
  • Orthographic half-size = shadow center body radius × 20 (gives ~100 texels per body radius)
  • Light positioned at 1000 scene units from shadow center body along sun direction
  • Terrain shader sun direction: transformed from world to body-local via inverse body quaternion. Guard against zero-length result (#998) — falls back to world-space direction or +Z default
  • near = LIGHT_DIST - halfSize × 2, far = LIGHT_DIST + halfSize × 2
  • Recalculated when reference body changes

Map view lighting:

Light Type Properties
Ambient AmbientLight Color: #060608
Star PointLight Position: system’s primary star, intensity: 3, decay: 0
  • Tone mapping: ACESFilmicToneMapping, exposure: 1.0
  • No shadows (map view is for navigation, not realism)

Starfield:

A procedural background starfield provides visual context for camera orientation.

Parameter Value
Star count 2,000
Distribution Uniform random on sphere
Sphere radius 50,000 scene units (within far plane)
Point size 1.5 pixels (sizeAttenuation: false)
Color White (0xffffff)
Depth write Disabled (depthWrite: false) — stars never occlude other objects
Random seed Fixed for deterministic placement across sessions

Behavior:

  • Rendered as THREE.Points on a large sphere
  • Starfield group position updated every frame to match camera position
  • Stars show no parallax from ship movement — only camera rotation changes the view
  • Stars appear fixed at infinity

Ship Shape from Inertia Tensor

Ships are rendered as ellipsoids whose proportions reflect the ship’s inertia tensor. This provides visual feedback about the ship’s rotational characteristics.

Conversion formula:

For a uniform-density ellipsoid with semi-axes a, b, c and mass m:

I_x = (1/5) * m * (b² + c²)
I_y = (1/5) * m * (a² + c²)
I_z = (1/5) * m * (a² + b²)

Solving for semi-axes from inertia tensor diagonal [I_x, I_y, I_z]:

a² = 2.5 * (-I_x + I_y + I_z) / m   // x-axis semi-axis
b² = 2.5 * (I_x - I_y + I_z) / m    // y-axis semi-axis
c² = 2.5 * (I_x + I_y - I_z) / m    // z-axis semi-axis

Example with default ship:

State Mass (kg) Inertia [I_x, I_y, I_z] (kg·m²) Semi-axes [a, b, c] (m)
Dry (no fuel) 10,000 [200k, 200k, 50k] [3.5, 3.5, 9.4]
Full fuel 20,000 [320k, 320k, 80k] [3.2, 3.2, 8.4]

The ship is elongated along the z-axis (forward direction), appearing as a cigar or capsule shape.

Visual scale:

Ships are always rendered at true scale (1e-7 scene units per meter). Use HUD body markers (B key) for ship visibility at orbital distances.

Parameter Value
Ship visual scale 1e-7 scene units/m
Other ships 0.2x own ship
Camera min distance 1e-8 units

Logarithmic depth buffer:

The renderer uses a logarithmic depth buffer to support the extreme range of scales:

  • Near plane: 1e-9 units (~10 mm at true scale)
  • Far plane: 1e5 units (~1 trillion km)
  • Allows rendering both true-scale ships and solar system bodies simultaneously

Nose indicator:

A small white cone at the forward end (+Z axis) indicates the ship’s thrust direction:

Element Value
Shape Cone
Height 50% of z-axis semi-axis
Radius 30% of min(x, y) semi-axis
Color White with slight emissive glow
Position At forward tip of ellipsoid

The nose indicator provides clear visual feedback for orientation, especially when the ellipsoid proportions are subtle.

Navigation lights:

Ships have colored marker lights for orientation visibility:

Light Position Color Purpose
Headlight Nose tip Yellow (#ffffcc) Forward direction
Port Left wingtip Red (#ff0000) Maritime convention
Starboard Right wingtip Green (#00ff00) Maritime convention

Implementation notes:

  • Lights are self-illuminated spheres (MeshBasicMaterial), not actual light sources
  • No PointLights to avoid illuminating other objects
  • Size proportional to ship semi-axes
  • Visible regardless of sun illumination
  • Nav lights are grouped under a child THREE.Group named navLights

Navigation light blink (aviation-style):

Nav lights blink with an aviation-style pattern: quick flash then long pause. Blink state is synchronized across all clients by deriving it from the game tick number.

Parameter Value
Pattern Flash on when tick % 16 < 2, off otherwise
Cycle ~16 ticks (~1.6s at 10 Hz tick rate)
Flash duration ~2 ticks (~0.2s)
Sync source state.gameTick (stored from data.tick in state messages)

Implementation notes:

  • Toggle navLights group visibility in extrapolatePositions() ship loop
  • All clients receive the same tick number, ensuring synchronized blink
  • The headlight (yellow) does NOT blink — only port/starboard nav lights

Engine bell:

A metallic engine nozzle/bell sits at the rear of the ship, providing a visible structure from which the engine plume emanates:

Parameter Value
Shape Truncated cone (CylinderGeometry — wider at exhaust end)
Top radius 20% of min(x, y) semi-axis × SHIP_VISUAL_SCALE (attached to body)
Bottom radius 35% of min(x, y) semi-axis × SHIP_VISUAL_SCALE (bell mouth)
Height 30% of z-axis semi-axis × SHIP_VISUAL_SCALE
Segments 12 radial
Color Dark grey (#555555), metalness 0.8, roughness 0.3
Position Flush with body rear: -Y at (c semi-axis + half bell height)

Implementation notes:

  • Uses MeshStandardMaterial for metallic appearance under scene lighting
  • Always visible (not tied to thrust state)
  • Engine plume position adjusted to sit behind the bell mouth

Engine plume:

A visual thruster effect appears at the rear of ships when thrust is active:

Parameter Value
Shape Inverted cone (pointing rearward, -Y in Three.js)
Height 80% of z-axis semi-axis × SHIP_VISUAL_SCALE (at full thrust)
Radius 25% of min(x, y) semi-axis × SHIP_VISUAL_SCALE
Outer color Orange (#ff8800), full emissive, transparent (opacity 0.7)
Inner color Yellow-white (#ffcc44), full emissive, transparent (opacity 0.8)
Inner size 60% of outer cone dimensions
Position Rear of ellipsoid (-Y at c semi-axis extent)
Visible Only when thrust_level > 0

Behavior:

  • Plume height scales linearly with thrust_level (0.0-1.0)
  • Subtle flicker animation: scale oscillates with sin(time * 15) * 0.1 + 0.9 (~10% amplitude)
  • Applies to own ship and all other visible ships
  • Initially hidden, toggled by thrust state updates

Dynamic updates:

The ellipsoid shape updates each server tick to reflect the current inertia tensor, which changes as fuel is consumed. Players can observe their ship visually “shrinking” slightly as fuel depletes.

The ellipsoid-based ship mesh described above applies to the Fast Frigate class. Other ship classes use distinct procedural geometry (see below).

Cargo Hauler Mesh

The Cargo Hauler uses a modular hull geometry distinct from the Fast Frigate’s ellipsoid. All dimensions are in meters, scaled by the visualization scale factor (1e-7). Geometry is built at meter scale (1 unit = 1 m) and the entire group is scaled by shipVisualScale to avoid WebGL float precision issues.

Spine (3 sections for visual variety):

Section Geometry Position (Y center) Purpose
Aft engineering Box 7×12×7 Y = -24 (Y: -30 to -18) Engine mounting, narrower
Mid cargo Box 8×36×8 Y = 0 (Y: -18 to +18) Main structure, pod attachment
Forward command Box 10×12×10 Y = 24 (Y: +18 to +30) Bridge, wider for command deck

Command module details:

  • Bridge viewport: 2 dark glass strips (Box 7×0.2×1.2) on the forward face (+Y) at Z = 3 and Z = 0.5
  • Viewport material: #112244, metalness 0.9, roughness 0.1, emissive #0a1828

Hull structural detail:

  • 4 longitudinal rails: Box 0.5×48×0.5 along spine corners at (±4.2, -6, ±4.2)
  • 3 structural ribs at section transitions:
    • Y = -18: Box 8.5×0.4×8.5 (aft-to-mid)
    • Y = 0: Box 8.5×0.4×8.5 (mid center)
    • Y = +18: Box 10.5×0.4×10.5 (mid-to-forward)

Cargo pods (×4):

Pod Position Geometry
Forward port (-8, +10, 0) Box 10×22×10
Forward starboard (+8, +10, 0) Box 10×22×10
Aft port (-8, -10, 0) Box 10×22×10
Aft starboard (+8, -10, 0) Box 10×22×10

Each pod has a cargo door panel (Box 6×14×0.2, color #776633) on the outward-facing X face, offset 5.1 m from pod center.

Docking clamps (×4, triple-cylinder mounts): Each clamp set has 3 cylinders oriented along X connecting a pod to the spine:

  • Main clamp: Cylinder r=1.2, h=5 at (±4, ±10, 0)
  • Z-offset supports: Cylinder r=0.6, h=5 at (±4, ±10, ±3)

Fuel tanks (×2):

  • Cylinder r=1.5, h=24, centered at (0, -6, ±5.5) on the Z-axis alongside the aft spine

Radiator panels (×2):

  • Thin box 0.15×20×8 at (0, -5, ±10) extending beyond the spine on the Z-axis
  • Support strut: Cylinder r=0.3, h=6 oriented along Z at (0, -5, ±7)
  • Material: #334455, metalness 0.3, roughness 0.6, double-sided

Nose and antenna:

  • Nose cone: Cone r=2.5, h=5 at Y = 32.5 (tip at Y = 35)
  • Antenna mast: Cylinder r=0.25, h=4 at Y = 37 (Y: 35 to 39)

Engine area:

  • Mounting crossbar: Box 8×1.5×3 at Y = -31
  • 2 engine bells: Cylinder top r=2, bottom r=4, h=6 at (±3, -33, 0)
  • 2 inner nozzles: Cylinder top r=1.4, bottom r=3.2, h=4.8 at (±3, -33.3, 0), color #333333

Materials:

Component Color Metalness Roughness
Spine (aft/mid) #666666 (dark grey) 0.7 0.4
Command module #777777 (light grey) 0.6 0.3
Cargo pods #887744 (khaki) 0.5 0.5
Cargo doors #776633 (dark khaki) 0.5 0.6
Structural (rails, ribs, clamps, bells) #555555 (grey) 0.8 0.3
Fuel tanks #5a5a6a (blue-grey) 0.6 0.4
Inner nozzles #333333 (dark) 0.9 0.2

Ship radius: For FOV threshold calculations, the Cargo Hauler’s effective radius is 35 m (half the diagonal bounding box).

Cargo Hauler Thrust Animation

The Cargo Hauler has dual exhaust plumes (one per engine bell), distinct from the Fast Frigate’s single orange plume.

Parameter Value
Shape Inverted cone (pointing rearward, -Y in Three.js) per engine
Height 80% of spine half-length (24 m at full thrust)
Radius 100% of engine bell bottom radius (4 m)
Outer color Blue (#4488ff), full emissive, transparent (opacity 0.7)
Inner color Light blue-white (#aaddff), full emissive, transparent (opacity 0.8)
Inner size 60% of outer cone dimensions
Position Behind each engine bell mouth
Visible Only when thrust_level > 0

Behavior:

  • Plume height scales linearly with thrust_level (0.0-1.0)
  • Same flicker animation as Fast Frigate: sin(time * 15) * 0.1 + 0.9 (~10% amplitude at ~15 Hz)
  • Both plumes animate in sync
  • Engine bell emissive glow: intensity scales with thrust_level (0 at idle, 0.5 at full)
  • At 0% throttle: plumes hidden, engine bell emissive = 0

Fast Frigate Mesh

The Fast Frigate uses a sleek, elongated hull with swept-back fins, sharing the Cargo Hauler’s design language (box/cylinder geometry at meter scale, scaled by SVS). Visually distinct — more aggressive and streamlined.

Hull (elongated cylinder with taper):

Section Geometry Position (Y center) Purpose
Main hull Cylinder r=3, h=12, 12 segments Y = 0 Primary structure
Forward taper Cone r_base=3, h=4, 12 segments Y = 8 (base at Y=6, tip at Y=10) Streamlined nose
Aft taper Cone r_base=3, h=3, 12 segments, inverted Y = -7.5 (base at Y=-6, tip at Y=-9) Engine section taper

Nose cone and headlight:

  • Nose cone: Cone r=1.2, h=3 at Y = 12.5 (tip at Y=14)
  • Headlight: Sphere r=0.5 at Y = 14 (nose tip), material: #ffffcc, MeshBasicMaterial, toneMapped: false, named ‘headlight’

Swept-back fins (×2):

Distinguishing visual feature — two fins at ~60% hull length.

Fin Position Geometry
Port fin (-4.5, 1, 0) Box 6×0.3×2, rotated Z = -25°
Starboard fin (4.5, 1, 0) Box 6×0.3×2, rotated Z = 25°

Material: #555555, metalness 0.8, roughness 0.3

Engine area:

  • Single engine bell: Cylinder top r=1.5, bottom r=2.5, h=3 at Y = -10.5
  • Inner nozzle: Cylinder top r=1.0, bottom r=2.0, h=2.4 at Y = -10.7, color #333333

Navigation lights:

  • Port (red): On port fin tip (-7, 2.2, 1)
  • Starboard (green): On starboard fin tip (7, 2.2, 1)
  • Forward (white): Above nose at (0, 13, 0)
  • Aft (amber): Below engine bell at (0, -13, 0)

Materials:

Component Color Metalness Roughness
Main hull #666666 (dark grey) 0.7 0.4
Forward/aft taper #777777 (light grey) 0.6 0.3
Nose cone #ffffff (white), emissive #333333
Fins, engine bell #555555 (grey) 0.8 0.3
Inner nozzle #333333 (dark) 0.9 0.2

Ship radius: For FOV threshold calculations, the Fast Frigate’s effective radius is 15 m.

Fast Frigate Thrust Animation

Single exhaust plume matching the Cargo Hauler’s blue-white color scheme.

Parameter Value
Shape Inverted cone (pointing rearward, -Y in Three.js)
Height 12 m at full thrust
Radius 100% of engine bell bottom radius (2.5 m)
Outer color Blue (#4488ff), full emissive, transparent (opacity 0.7)
Inner color Light blue-white (#aaddff), full emissive, transparent (opacity 0.8)
Inner size 60% of outer cone dimensions
Position Behind engine bell mouth
Visible Only when thrust_level > 0

Behavior:

  • Same scaling and flicker animation as other ship classes

Directional Indicator Lights

Navigation and orientation lights on ship hulls. These are self-illuminated markers (MeshBasicMaterial, toneMapped: false) — not scene light sources.

Fast Frigate lights (existing, unchanged):

Light Position Color
Port Left (-X) wingtip Red (#ff0000)
Starboard Right (+X) wingtip Green (#00ff00)
Headlight Nose tip (+Z) Yellow (#ffffcc)

Cargo Hauler lights:

Light Position (Three.js coords) Color Purpose
Port (-13.8, 12, 5.8) — outer/front corner of forward port pod Red (#ff0000) Maritime convention
Starboard (13.8, 12, 5.8) — outer/front corner of forward starboard pod Green (#00ff00) Maritime convention
Forward (0, 36, 0) — above nose cone tip White (#ffffff) Forward direction
Aft (0, -37, 0) — below engine bells Amber (#ffaa00) Aft direction

All lights:

  • Sphere radius 0.8 m (MeshBasicMaterial, toneMapped: false)
  • Blink with aviation-style pattern (same tick-modulo logic as fast frigate nav lights)
  • Emissive material, visible regardless of sun illumination
  • MUST be positioned on outer surfaces, not inside hull geometry

Motion Smoothing

The server sends state updates at 1 Hz (once per second), but the client renders at 60 FPS. To avoid jerky movement, the client uses client-side extrapolation to predict positions between server updates.

Extrapolation algorithm (quadratic):

// On each render frame (60 FPS):
real_delta = current_time - last_server_update_time
game_delta = real_delta * time_scale   // velocity is in game-seconds
// Compute gravitational acceleration from all bodies (n-body)
accel = sum(-G * M_j * (pos - pos_j) / |pos - pos_j|³)
extrapolated_position = last_known_position + velocity * game_delta
                      + 0.5 * accel * game_delta²

// On server update (1 Hz):
last_known_position = server_position
last_server_update_time = current_time
velocity = server_velocity

Quadratic extrapolation uses the gravitational acceleration computed from known body masses and positions to account for orbital curvature. This reduces inter-tick position error from O(dt²) (linear) to O(dt³) (quadratic), which is critical at high time scales where each tick represents many game-seconds of orbital motion.

Per-view Verlet integration:

Both cockpit and map views independently perform velocity Verlet integration of _clientPos/_clientVel each render frame. Only one view is active at a time (main.js calls either cockpitView.animate() or mapView.animate()), so each view must advance positions itself — shared state is not stepped by an inactive view.

Each view tracks _lastFrameTime to compute frame-to-frame delta:

const realFrameDt = this._lastFrameTime > 0 ? now - this._lastFrameTime : 0;
this._lastFrameTime = now;
const gameFrameDt = Math.min(realFrameDt, 0.1) * (state.timeScale || 1.0);

On view activation, _lastFrameTime is reset to 0 to avoid a large initial dt from time spent in the other view.

Behavior:

Aspect Description
Update rate Server: 1 Hz, Client render: 60 FPS
Prediction Velocity Verlet integration using frame-to-frame dt × time scale
Correction Error-fade blending: on each server update, compute extrapolation error (predicted − actual), then fade the offset to zero over 200 ms (~12 frames at 60 FPS). Both bodies and ships use quadratic prediction for blend offset.
Applies to Ships (own and others), celestial bodies, stations, jump gates
Active in Both cockpit view and map view (independent integration)

Why quadratic extrapolation is needed at high time scales:

  • At 100× time scale, each tick represents 100 game-seconds
  • A ship in LEO moves ~6.5° per tick — linear extrapolation creates ~43 km error
  • Quadratic extrapolation reduces this to ~1.6 km (26× improvement)
  • The residual blend offset becomes sub-visual-threshold at 100×

Attitude handling:

Ship attitude is extrapolated using angular velocity for smooth rotation:

// Coordinate systems:
// Physics/ICRF: right-handed, Z=north ecliptic pole
// Three.js:     right-handed, Y=up
// Transform:    (x, y, z) → (x, z, -y)  (−90° rotation about X axis)
//   This is a proper rotation (det=+1), preserving handedness.
//   The old (x, z, y) swap was a reflection (det=−1) that reversed
//   all cross products, making prograde orbits appear retrograde.
//
// Quaternion transform: physics (w,x,y,z) → Three.js Quaternion(x, z, -y, w)
base_quat = Quaternion(
  server_attitude.x,
  server_attitude.z,
  -server_attitude.y,
  server_attitude.w
)

// Transform angular velocity the same way: (wx,wy,wz) → (wx, wz, -wy)
wx = server_angular_velocity.x
wy = server_angular_velocity.z
wz = -server_angular_velocity.y

// Extrapolate rotation
angular_speed = magnitude(wx, wy, wz)
if angular_speed > 0:
  angle = angular_speed * delta_time
  axis = normalize(wx, wy, wz)
  delta_quat = quaternion_from_axis_angle(axis, angle)
  ship.quaternion = base_quat * delta_quat  // Body frame rotation
else:
  ship.quaternion = base_quat

This provides smooth 60 FPS rotation between 1 Hz server updates.

Tracer Lines

Ships can display trajectory tracer lines showing recent path history:

Setting Value
Toggle key T
Default state Off
Trail length 600 positions (10 minutes at 1 Hz tick rate)
Own ship color Green (fading to transparent)
Other ships color Orange (fading to transparent)
Reference frame Body-relative (dynamic reference body)

Body-relative coordinate storage:

Tracer positions are stored relative to the current SOI reference body (same as HUD reference body) rather than in absolute heliocentric coordinates. This ensures the tracer shows the actual orbital trajectory around the body, not a distorted path combining orbital motion with the body’s own motion around the Sun.

# When storing a new tracer point:
relative_position = ship_position - reference_body_position

# When rendering the tracer:
absolute_position = stored_relative_position + current_reference_body_position

This approach:

  • Shows correct orbital paths around any body (ellipses, not spirals)
  • Keeps the tracer shape consistent as the reference body moves
  • When the reference body changes (e.g., escaping a moon’s SOI), trace history is cleared since old points are relative to the previous body

Behavior:

  • Stores last 60 position updates per ship (body-relative coordinates)
  • Renders as line segments connecting historical positions (transformed to absolute)
  • Older segments fade toward transparent
  • Trail clears on service reset (position jump)
  • State persists during session (not saved)

Rendering optimization:

  • Tracers use persistent BufferGeometry with pre-allocated vertex buffer (MAX_TRACE_POINTS × 3 floats)
  • Vertices updated in-place; setDrawRange() controls visible portion
  • Material and THREE.Line created once per tracer and reused
  • Full rebuild only on: reference body change, position jump (respawn), or visibility toggle

Ship-centered floating origin (Float32 precision fix):

At large orbital radii (e.g., 10M km Sun orbit → 1000 Three.js units after cockpit scale), Float32 vertex buffers suffer quantization jitter (~600m at 1000 units). To fix this, tracer vertices use ship-centered offsets:

  • Vertex values are stored as (tracePoint - shipRelativePosition) * SCALE instead of tracePoint * SCALE
  • The mesh is positioned at referenceBodyPosition + shipRelativePosition * SCALE instead of referenceBodyPosition
  • This ensures vertex 0 (ship position) is always at (0,0,0) with full Float32 precision
  • Nearby vertices are small values with good precision; distant vertices (opposite side of orbit) have lower precision but are visually far away where jitter is imperceptible
  • The ship-relative pivot (_myTracerPivot) is stored when the tracer is updated and reused in per-frame position updates
  • The same pivot offset is applied to the tracer connector line endpoints
  • Other ships’ tracers use the same technique, with each tracer storing its own pivot from the latest trace point

Predicted Orbital Path

Displays the predicted orbital trajectory as a Keplerian orbit line computed analytically from orbital elements.

Parameter Value
Toggle key N
Default state Off
Color Cyan (#00ccff), 70% opacity
Depth write Disabled
Reference frame Body-relative (current reference body via M/r²)
Update Recomputed when orbit changes significantly; position follows body each frame

Change-detection criteria (recompute when any is true):

  • Reference body changes
  • Semi-major axis changes by > 0.1% OR eccentricity changes by > 0.001
  • Thrust is active (thrust_level > 0)
  • Path was just enabled (toggle on)

Rendering optimization:

  • Persistent BufferGeometry with pre-allocated vertex buffer (362 × 3 floats); setDrawRange() for actual point count
  • Material and THREE.Line created once and reused
  • Orbital event markers only recomputed when orbital path is recomputed (same dirty flag)
  • Orbit path recomputed every tick to keep the sweep start (nuShip) fresh
  • For elliptical orbits, the first and closure vertices are updated per-frame to the ship’s extrapolated body-relative position, ensuring the orbit line always passes through the ship at true scale

Ship-centered floating origin (Float32 precision fix):

Same technique as tracers — all orbital path vertices are stored as offsets from the ship’s body-relative position rather than raw body-relative coordinates. The mesh is positioned at referenceBodyPosition + shipRelativePosition * SCALE. The pivot (_orbitalPathPivot) is computed when the path is generated and reused in per-frame vertex updates (first/closure vertices subtract the pivot before scaling). Orbital event markers use the same pivot for their positions.

Math: Compute h = r × v, e_vec = (v × h)/μ - r̂, p = |h|²/μ, then sample r(ν) = p/(1 + e·cos ν) with basis vectors p̂ = normalize(e_vec), q̂ = normalize(h × e_vec).

Orbit types:

  • Elliptical (e < 1): full orbit, 360 points, sweep starts at ship’s current true anomaly (guarantees first vertex is exactly at ship position for true-scale accuracy)
  • Hyperbolic (e ≥ 1): arc extends to SOI boundary (1.1× SOI radius) or asymptote margin, whichever is further; 180 points + ship’s true anomaly inserted as extra vertex if not coincident with a sample point
  • Near-circular (e < 0.001): use as basis instead of e_vec (ship true anomaly is naturally 0)
  • Near-radial (p < rShip × 0.001): draw radial line along (ship’s radial direction from body center) extending ±1.5× ship distance (or to SOI boundary if in a secondary body’s SOI); orbit collapses to a line when semi-latus rectum is negligible relative to ship distance (radial plunge / escape trajectories)
  • Degenerate ( h < 1e-10): no path shown
Orbital Event Markers

Labeled event markers displayed on the predicted orbital path:

Marker Condition Label Color
Periapsis Non-circular (e >= 0.001) Pe + altitude + time-to #00ff88 (green)
Apoapsis Elliptical, non-circular (e < 1, e >= 0.001) Ap + altitude + time-to #00ccff (cyan)
Ascending Node Inclined orbit (0.5° < i < 179.5°) AN + time-to #cc88ff (purple)
Descending Node Inclined orbit (0.5° < i < 179.5°) DN + time-to #cc88ff (purple)
SOI Escape Orbit crosses SOI radius ESC + time-to #ffaa00 (orange)
SOI Entrance Orbit crosses child body’s Hill sphere ENT + child name + time-to #00ddff (cyan)
SOI Entry (all) Orbit enters any child body’s Hill sphere SOI_EN + child name + time-to #00ffff (cyan)
SOI Exit (all) Orbit exits any child body’s Hill sphere SOI_EX + child name + “exit” + time-to #00ffff (cyan)
Body Intersection Periapsis < 0 IMPACT + time-to #ff4444 (red)

Near-radial marker handling (p < r × 0.001):

When the trajectory is near-radial, conic position formulas degenerate (r(ν) produces meaningless values). Markers use direct radial geometry instead:

  • Periapsis: placed at p/(1+e) along -r̂ (toward body center); for radial plunge this is approximately at the body center
  • IMPACT: placed at body.radius along (body surface in ship’s radial direction); time-to = (r - body.radius) / |v_radial| when v_radial < 0 (inbound)
  • ESC: suppressed (ν_SOI ≈ ν_asymptote makes conic position unreliable)
  • Apoapsis, AN, DN: computed normally (use true anomaly, not conic position)

SOI radius computation:

The sphere of influence (SOI) radius is computed as the Hill sphere of the reference body relative to its dominant gravitational parent (found via the same M/r² model used for reference body selection):

r_SOI = d × (m_body / (3 × m_parent))^(1/3)

where d is the current distance between the reference body and its parent. The ESC marker is placed where the predicted trajectory crosses this radius, with time-to showing when the ship will reach that point. Only the single nearest future crossing is shown — of the two candidate true anomaly values (±ν_SOI), the one with the smallest positive time-to-arrival is selected.

SOI entrance (ENT) marker:

The ENT marker predicts when the ship’s Keplerian trajectory will enter a child body’s SOI (Hill sphere). For each child body of the current reference body:

  1. Get child position relative to reference body (projected into orbital plane coordinates)
  2. Compute child’s Hill sphere radius via computeHillSphereRadius
  3. Sample ship orbit at N points (360 elliptical, 180 hyperbolic)
  4. At each sample, compute distance from orbit point to child center
  5. Find segments where distance crosses inward through rSOI (entry point)
  6. Bisect to refine the true anomaly at crossing (~10 iterations for sub-degree precision)
  7. Compute time-to-encounter

Only the single nearest future encounter across all children is displayed. Uses a static child position approximation (child body position held fixed at current value), which is valid when the ship’s orbital period is short relative to the child’s orbital period.

All child SOI crossings (SOI_EN / SOI_EX markers):

While the ENT marker shows only the single nearest future child SOI encounter, the SOI_EN and SOI_EX markers show ALL entry and exit crossings where the ship’s Keplerian orbit passes through any child body’s Hill sphere. The algorithm (computeAllChildSOICrossings) is similar to the ENT detection but:

  1. Detects BOTH inward crossings (entry, prevDist > rSOI && dist <= rSOI) and outward crossings (exit, prevDist <= rSOI && dist > rSOI)
  2. Returns ALL crossings, not just the nearest
  3. Edge cases: orbit starting inside SOI produces exit without entry; hyperbolic orbit ending inside SOI produces entry without exit

SOI_EN markers display ["SOI_EN", childName, timeTo]. SOI_EX markers display ["SOI_EX", "childName exit", timeTo]. Both use color #00ffff (cyan), matching the orbit diagram SOI intersection segments.

The ENT marker is preserved alongside SOI_EN/SOI_EX. The SOI_EN entry that duplicates the ENT marker (same child body, time within 1%) is suppressed to avoid visual doubling.

Rendering:

  • Canvas-texture sprites (billboard toward camera)
  • depthWrite disabled, depthTest disabled, sizeAttenuation disabled (constant screen-space size)
  • Small colored dot (4 px, sizeAttenuation disabled) placed on the orbital path at each marker position, matching the marker’s color
  • Text label offset radially outward from the dot by a fixed screen-space distance (2% of viewport height, computed from camera distance and FOV), with a thin callout line (50% opacity) connecting dot to label
  • Markers maintain legible size regardless of camera zoom distance
  • Tied to orbital path toggle (N key) — no separate toggle

Time-to computation:

  • Elliptic: ν → E via tan(E/2) = √((1-e)/(1+e))·tan(ν/2), then M = E - e·sin(E), Δt = ΔM/(2π) × T
  • Hyperbolic: ν → H via tanh(H/2) = √((e-1)/(e+1))·tan(ν/2), then M_h = e·sinh(H) - H, Δt = ΔM_h / n where n = √(μ/(-a)³)
  • Always forward in time (wrap ΔM positive for elliptical orbits)

Time-to display format:

Range Format
< 120 s T-Xs
< 120 min T-Xm
< 48 h T-Xh
≥ 48 h T-Xd

Body Markers

Toggleable name + distance labels on celestial bodies in cockpit view, providing identification at a glance.

Setting Value
Toggle key B
Default state Off
Applies to All celestial bodies

Marker content:

Each marker displays:

  • Colored dot (matching body type indicator colors)
  • Body name
  • Distance from player’s ship

Indicator dot colors by body type:

Type Color RGB Hex
Star Yellow #ffdd44
Planet (rocky) Tan #d2b48c
Planet (gas giant) Orange #ff9944
Planet (ice giant) Cyan #44ddee
Moon Gray #aaaaaa

These colors are shared with the map view indicators via bodyConfig.js.

Reference body highlight:

The current reference body’s marker is highlighted in cyan (#00ffff) for both the dot and name label, making it easy to identify which body the ship is orbiting.

Distance display format:

Range Unit Format
< 1,000 m m integer
1 km – 999 km km integer
1,000 km – 999 Mm Mm 1 decimal
1 Gm – 999 Gm Gm 2 decimals
≥ 0.01 AU AU 2 decimals

Rendering:

  • Uses CSS2DRenderer (HTML overlay), same as map view indicators
  • Reuses .map-indicator CSS classes for consistent styling
  • CSS overlay has pointer-events: none (markers are non-interactive in v1)
  • No off-screen indicators or occlusion handling (v1)
  • Distance values (_distMeters) are computed every frame (needed for de-overlap sorting)
  • DOM text/style updates (formatted distance, colors) are throttled to 4 Hz to avoid unnecessary reflows

Settings integration:

  • Per-category checkbox in Settings window Markers section (see Settings Window)
  • B key and View menu “Body Markers” item act as master toggle (all on / all off)
  • Each category can be toggled independently via the Settings checkboxes
  • Visibility persisted per-category in markerVisibility settings object
Ship Markers

Ship markers display name and distance labels for other players’ ships. Controlled independently by the markerVisibility.ships setting.

Appearance:

  • Orange dot (#ff8844) to distinguish from celestial body markers
  • Player name label
  • Distance from own ship, updated per frame using the same formatDistance() ranges as body markers

Lifecycle:

  • Created when a new ship state is first received from the server
  • Position updated during extrapolation (same as ship mesh position)
  • Removed when a player disconnects (along with ship mesh cleanup)

Implementation:

  • Reuses CSS2DObject, .map-indicator CSS classes, and cssRenderer from body markers
  • Ship indicators stored in this.shipIndicators map (keyed by ship ID)
  • Visibility controlled by markerVisibility.ships (independent of body markers)
Lagrange Point Markers

L1–L5 Lagrange points are displayed as HUD markers for body pairs involving the player’s current reference body. These markers help players navigate to gravitationally significant locations.

Which pairs are shown:

  • Parent pair: (parent, referenceBody) — e.g., if orbiting Earth, shows Sun–Earth L1–L5
  • Child pairs: (referenceBody, child) for each child — e.g., Earth–Luna L1–L5
  • Markers rebuild when the reference body changes

Appearance:

  • Magenta dot (#cc44ff) with label format “Primary–Secondary Ln” (e.g., “Sun–Earth L1”)
  • Distance from ship, using the same formatDistance() ranges as body markers

Toggle:

  • Controlled by markerVisibility.lagrangePoints (independent per-category toggle)
  • B key acts as master toggle (all categories on/off together)
  • Visible in both cockpit and map views

L-point computation (Hill’s approximation):

Given primary (mass m1, position p1) and secondary (mass m2, position p2, velocity v2):

r = p2 - p1              (separation vector)
d = |r|                   (distance)
u = r / d                 (unit vector)
α = (m2 / (3 × m1))^(1/3)  (Hill's ratio)
μ = m2 / (m1 + m2)       (mass parameter)
Point Position Description
L1 p1 + u × d × (1 - α) Between bodies
L2 p1 + u × d × (1 + α) Beyond secondary
L3 p1 - u × d × (1 + 5μ/12) Opposite side of primary
L4 p1 + rotate(r, +60°, n) Leading equilateral point
L5 p1 + rotate(r, -60°, n) Trailing equilateral point

For L4/L5, n = normalize(r × v_rel) is the orbit normal, and the rotation uses Rodrigues’ formula:

v_rot = r·cos(θ) + (n × r)·sin(θ) + n·(n·r)·(1 - cos(θ))

Implementation:

  • Pure math module lagrangePoints.js with computeLagrangePoints(primary, secondary) and getRelevantPairs(refBodyName, bodyData)
  • LAGRANGE_POINT_COLOR constant in bodyConfig.js
  • Indicators stored in this.lagrangeIndicators map (keyed by “Primary-Secondary-Ln”)
  • Positions recomputed each frame from extrapolated body positions
  • Indicators rebuilt when reference body changes (this._currentLagrangeRefBody tracking)

Crosshair / Reticle

A centered HUD crosshair in cockpit view provides an aim/orientation reference. In map view, a pulsing ring highlights the player’s own ship indicator.

Cockpit view crosshair:

Parameter Value
Element Fixed-position HTML overlay (#cockpit-crosshair)
Shape Thin cross (1px lines) with center dot (3px)
Color Green (rgba(0, 255, 0, 0.6)), dot at 0.8 opacity
Size 40px × 40px
Position Screen center (top: 50%; left: 50%; transform: translate(-50%, -50%))
z-index 550
Pointer events None (click-through)
Default state Enabled

Map view own-ship ring:

Parameter Value
Element div.map-indicator-ring appended to own-ship indicator container
Shape Circle border (1px solid)
Color Green (rgba(68, 255, 68, 0.7))
Size 20px × 20px
Animation 2s ease-in-out pulse: scale 1→1.5, opacity 0.8→0.3
Pointer events None

Toggle:

Input Action
C key Toggle crosshair on/off (both views)
View menu “Crosshair” item with checkmark
Settings “Crosshair” checkbox (synced)

Implementation:

  • CockpitView.crosshairEnabled property (default: true)
  • MapView.crosshairEnabled property (default: true)
  • Cockpit: show/hide #cockpit-crosshair element on activate/deactivate and toggle
  • Map: show/hide .map-indicator-ring on own-ship indicator

Body Rotation

Celestial bodies rotate around their axes with realistic rotation periods and axial tilts.

Rotation data:

Each body has the following rotation parameters from the ephemeris:

Parameter Description
rotation_period Sidereal rotation period in seconds (negative = retrograde)
axial_tilt Obliquity in degrees (tilt relative to orbital plane)

Example rotation periods:

Body Period Axial Tilt Notes
Earth 23.93 hours 23.44° Standard reference
Jupiter 9.93 hours 3.13° Fastest planet
Venus -243 days 177.36° Retrograde, nearly upside down
Uranus -17.24 hours 97.77° Retrograde, sideways rotation

Rotation calculation:

Planet spin axes are defined in ecliptic coordinates as IAU pole direction unit vectors (see celestial-bodies.md Spin Axes). The BODY_SPIN_AXES lookup in bodyConfig.js provides these vectors for stars and planets.

Moons inherit their parent planet’s spin axis, since tidally locked moons orbit in (or near) the parent’s equatorial plane. A helper function getBodySpinAxis(name) resolves this: it checks BODY_SPIN_AXES[name] first, then falls back to BODY_SPIN_AXES[BODY_PARENTS[name]], and finally to ecliptic north [0, 0, 1].

// Current rotation angle in radians
// The coordinate transform (x,y,z)→(x,z,-y) is a proper rotation (det=+1),
// so setFromAxisAngle follows the right-hand rule without negation.
rotation_angle = (game_time_seconds / rotation_period) * 2π

// Look up spin axis (moons inherit parent's axis)
axis = getBodySpinAxis(name)
// Transform spin axis from ecliptic (x,y,z) to Three.js (x,z,-y)
spin_axis = Vector3(axis[0], axis[2], -axis[1]).normalize()

// Tilt quaternion: rotate from ecliptic north (0,1,0) to spin axis direction
ecliptic_up = Vector3(0, 1, 0)
tilt_quat = quaternion_from_unit_vectors(ecliptic_up, spin_axis)

// Spin quaternion: rotate around the spin axis
spin_quat = quaternion_from_axis_angle(spin_axis, rotation_angle)

// Compose: spin first (world-space), then tilt
body.quaternion = spin_quat * tilt_quat

Visualization:

  • Rotation is most visible with wireframe overlay enabled (G key)
  • Fast rotators (Jupiter, Saturn) show noticeable spin
  • Slow rotators (Venus, Mercury) appear nearly static
  • Retrograde rotation (negative period) spins in opposite direction

Saturn’s Rings

Saturn renders a semi-transparent textured annular disc representing its ring system. The ring mesh is a child of the Saturn body mesh and inherits its tilt/spin quaternion automatically.

Ring dimensions:

Parameter Value Description
Inner radius 74,500 km C ring inner edge (1.28× Saturn radius)
Outer radius 140,200 km F ring (2.41× Saturn radius)
Segments 64 (cockpit), 32 (map) Ring geometry resolution

Ring structure encoded in texture:

Region Radial position Appearance
C ring 0–24% Dim, semi-transparent
B ring 24–59% Bright, opaque (densest)
Cassini division 59–65% Nearly transparent gap
A ring 65–85% Medium brightness
Encke gap ~77% Thin dark line
Beyond A ring 85–100% Transparent

Texture:

A 1024×64 PNG strip (saturn-ring.png) where the X axis maps to radial distance from inner to outer edge. The texture encodes ring brightness and transparency (Cassini division, Encke gap as transparent regions). Color is warm tan/gold varying by ring band.

UV mapping:

RingGeometry default UVs are remapped so U goes 0→1 from inner to outer radius (radial):

for each vertex:
  r = distance from center
  u = (r - innerRadius) / (outerRadius - innerRadius)
  v = 0.5

Material:

Property Value
Type MeshStandardMaterial
Side DoubleSide (visible from above and below)
Transparent true
Roughness 0.9
Metalness 0.0

Uranus’s Rings

Uranus renders a semi-transparent dark annular disc representing its ring system. Like Saturn, the ring mesh is a child of the Uranus body mesh and inherits its tilt/spin quaternion automatically (Uranus’s ~97.8° axial tilt makes the rings nearly perpendicular to the orbital plane).

Ring dimensions:

Parameter Value Description
Inner radius 41,837 km Ring 6 inner edge (1.64× Uranus radius)
Outer radius 51,149 km Epsilon ring outer edge (2.00× Uranus radius)
Segments 64 (cockpit), 32 (map) Ring geometry resolution

Material (no texture — procedural dark color):

Uranian rings are extremely dark (albedo ~0.05), so a solid dark material with transparency is used instead of a texture.

Property Value
Type MeshStandardMaterial
Color #222233 (dark blue-grey)
Side DoubleSide
Transparent true
Opacity 0.4
Roughness 0.9
Metalness 0.0

The ring mesh has userData.isRing = true and casts/receives shadows.

Wireframe Overlay

Celestial bodies can display a wireframe overlay to help visualize geometry and rotation.

Setting Value
Toggle key G
Default state Off
Applies to Celestial bodies only (not ships)
Color White, 20% opacity

Implementation:

  • Wireframe mesh 1% larger than body (1.01×) to prevent z-fighting
  • Uses polygonOffset for additional depth buffer separation
  • Added as child of body mesh, inherits position/rotation
  • Lower polygon count than solid mesh for cleaner lines

Orbit Diagram Window

A floating window with an SVG-rendered 2D orbit diagram showing labeled Keplerian orbital elements, apsides, and nodes with a numeric table of orbital parameters. Available in both cockpit and map views.

Setting Value
Toggle key I
Menu location View > Orbit Diagram
Default state Hidden
Draggable Yes (via title bar)
Position top: 48px; left: 20px
Width 320px

SVG Diagram (viewBox 0 0 300 288):

The top 200px of the SVG shows a 2D projection of the orbit in its own orbital plane. The reference body sits at the focus of the ellipse (center of the SVG diagram area). Below the diagram, a compact numeric table displays the six Keplerian elements plus periapsis/apoapsis altitudes.

SVG Elements:

Element Visual Color Description
Orbit path <ellipse> (e<1), <path> (e≥1), or radial <path> line (near-radial) #335 Orbit shape centered at focus. Near-radial (p < rShip × 0.001): straight line through body center along periapsis direction; ship dot placed at actual distance using denominator guard (fallback to shipDistance when conic denominator ≤ 0).
Reference plane Dashed horizontal line #333 Equatorial reference through focus
Reference body Circle at focus, scaled to true radius (clamped 2–40 px, min 2 px visibility) Body color Central body drawn to scale with orbit
Ship position Circle (r=4) on orbit #0f0 Current position at true anomaly
Periapsis (Pe) Circle (r=3) + label #00ff88 Closest approach point (ν=0)
Apoapsis (Ap) Circle (r=3) + label #00ccff Farthest point (ν=180°, elliptical only)
Ascending node (AN) Circle (r=3) + label #cc88ff Orbit crosses reference plane upward
Descending node (DN) Circle (r=3) + label #cc88ff Orbit crosses reference plane downward
Arg of periapsis (ω) Arc from AN to Pe #ff8800 Angular arc near focus
True anomaly (ν) Arc from Pe to ship #00ff00 Angular arc near focus
Hill sphere (SOI) Dashed circle centered on body #ffaa00 (30% opacity) SOI boundary at Hill sphere radius; always shown when Hill sphere radius is available (rSOI > 0). May extend beyond the SVG viewBox for small orbits relative to SOI (natural clipping).
SOI Escape (ESC) Circle (r=3) + label #ffaa00 Nearest future SOI escape point; shown only when orbit crosses Hill sphere and e≥0.001. Hidden when orbit fits entirely within SOI.
SOI Entrance (ENT) Circle (r=3) + label #00ddff Nearest future child body SOI entrance; shown when Keplerian orbit crosses a child body’s Hill sphere. Label shows child body name + time-to-encounter. Only the single nearest future encounter across all children is displayed. Uses static child position approximation (child position held fixed at current value).
Impact (IMPACT) Circle (r=3) + label #ff4444 Nearest future surface intersection; shown when periapsis < body radius and e≥0.001. Only the single nearest future crossing is displayed (the ship will not survive to reach the second).
Child SOI circles Dashed circles at each child body’s projected position #ffaa00 (20% opacity) Shown for each child body of the current reference body (e.g., Luna when orbiting Earth, Galilean moons when orbiting Jupiter). Each circle’s center is the child body’s position projected onto the ship’s orbital plane using the heading basis vectors (eN, eP), and its radius is the child’s Hill sphere radius scaled to diagram coordinates. Dynamically created/removed as reference body changes. Labels show abbreviated child name.
SOI intersection segments Polyline overlays along orbit path #0ff (2px stroke) Shown where the ship’s Keplerian orbit passes through a child body’s SOI. Each segment is drawn from entry to exit true anomaly using ~30 sample points with posAtNu(). Multiple segments may appear if the orbit passes through multiple child SOIs. Computed by findChildSOISegments() in orbitDiagramCalc.js.
SOI entry marker Circle (r=2.5) + label #0ff Marks where orbit enters a child body’s SOI. Label shows child body name (e.g., “Luna”).
SOI exit marker Circle (r=2.5) + label #0ff Marks where orbit exits a child body’s SOI. Label shows “{childName} exit” (e.g., “Luna exit”).
Direction arrow <polygon> triangle (5px) #88ff88 Orbit direction of travel; positioned 15° ahead of ship on orbit path, rotated tangent to orbit. For hyperbolic orbits, clamped within visible arc.
Heading tick <line> (12px, stroke-width 2) #0f0 Ship heading direction projected onto the orbital plane; extends from ship dot. Uses quaternion rotation to compute forward vector (+Z in ICRF body frame), projects onto orbital plane basis vectors: eN = ascending node direction and eP = prograde in-plane perpendicular (h × eN). Both are ICRF vectors returned by calculateOrbitalElements(), computed from the actual node vector and angular momentum relative to the body’s spin axis (equatorial plane), not hardcoded ecliptic formulas. Hidden when projection magnitude < 0.01 (heading nearly normal to orbital plane). Updated per animation frame with SLERP-interpolated attitude (via updateOrbitDiagramHeading()) for smooth motion between server ticks. Orbital plane basis vectors and ship SVG position are cached in refs._headingCtx during the full tick-rate update.
Escape indicator Text label #ffaa00 “ESCAPE” shown when e≥1

Numeric Table (below diagram, y > 200):

a: <value>   e: <value>
i: <value>   Ω: <value>
ω: <value>   ν: <value>
Pe: <value>  Ap: <value>
T: <value>   P%: <value>
  • T (orbital period): formatted via formatPeriod() (s / min / h / d). Shows “Esc” for escape trajectories (e ≥ 1). Tooltip: “Orbital period — time for one complete orbit”.
  • P% (Keplerian perturbation ratio): ratio of gravitational acceleration from all non-reference bodies to the acceleration from the reference body, displayed as a percentage. Indicates how much the actual trajectory will deviate from the Keplerian orbit projection.
    • Formula: ratio = |Σ GM_i · (pos_i − pos_ship) / |pos_i − pos_ship|³| / (GM_ref / r_ref²) where the sum is a vector sum over all bodies except the reference body.
    • Adaptive precision: 0.03%, 1.2%, 14% (fewer decimals for larger values).
    • Color coding: green (#4f4) < 1%, yellow (#ff0) 1–10%, red (#f44) > 10%.
    • Tooltip: “Keplerian perturbation — ratio of third-body gravity to reference body gravity. Low values mean the projected orbit is reliable. High values (near SOI boundary or Lagrange points) mean the actual trajectory will diverge from the Keplerian projection.”
    • Shows “N/A” if no reference body or data missing.

Coordinate mapping (2D orbital plane projection):

  • x-axis (SVG): points toward ascending node direction
  • y-axis (SVG, inverted): prograde direction at ascending node
  • Reference plane shown as horizontal dashed line through focus
  • Position at true anomaly ν: r = p / (1 + e·cos(ν)), mapped to SVG coordinates at angle ω + ν from ascending node
  • For near-equatorial orbits (i < 0.1° or i > 179.9°) where ω is undefined but e > 0.001, the orbit uses longitude of periapsis ϖ — the angle from the equatorial reference direction to the eccentricity vector, measured in the orbital plane: cos(ϖ) = ref · ê, sin(ϖ) = (ref × ê) · ĥ. The ship is then placed at true anomaly ν from periapsis: total angle = ϖ + ν. This replaces the argument of latitude fallback which incorrectly placed periapsis at angle 0 regardless of actual periapsis direction.
  • For near-equatorial orbits with e ≤ 0.001 (near-circular), the ship position falls back to argument of latitude u = ω + ν computed directly from position and node vectors: cos(u) = r̂ · n̂, sin(u) = (n̂ × r̂) · ĥ. In this case periapsis is undefined and not shown.
  • For retrograde orbits (i > 90°), the y-axis is flipped so the orbit appears clockwise as viewed from ecliptic north
  • Heading tick basis vectors: The heading tick projects the ship’s 3D attitude quaternion onto the 2D orbital plane using basis vectors eN (ascending node direction) and eP (prograde at ascending node, = ĥ × eN). For near-equatorial orbits where the node vector is undefined, the basis uses the equatorial reference direction as eN instead, so the heading tick aligns with the longitude-of-periapsis–based diagram orientation. This prevents a 23.4° offset that would occur if the ecliptic (ICRF) x-axis were used as fallback.

Target Overlay:

When a target is selected (state.targetId), the diagram overlays the target’s position, orbit, and phase angle onto the ship’s orbital plane.

Element Visual Color Description
Target dot Circle (r=3) #ff8800 Target position projected onto ship’s orbital plane
Target label Text (font-size 8) #ff8800 Target display name next to dot
Target orbit Dashed <ellipse> (e<1) or <path> (e≥1) #884400 Target’s Keplerian orbit projected onto ship’s orbital plane
Phase arc Dashed <path> arc (r=16, opacity 0.5) #ff8800 Angular separation between ship and target
Phase label Text (font-size 8) #ff8800 Phase angle in degrees (e.g., “23.4°”) at arc midpoint

Target projection rules:

  • Target’s 3D body-relative position is projected onto the ship’s orbital plane using the heading basis vectors (eN, eP from calculateOrbitalElements())
  • Target’s eccentricity vector is projected onto eN/eP to determine its argument of periapsis in diagram space
  • Target orbit is drawn with the target’s own semi-major axis and eccentricity, rotated by the projected argument of periapsis
  • Phase angle: angular difference between ship position angle (ω + ν) and target projected angle in diagram space, normalized to [-180°, 180°]

Scale toggle:

  • Default: ship orbit fills diagram (current behavior, _scaleMode = 'ship')
  • Click body dot or press Z to toggle to “fit both” mode (_scaleMode = 'both'): scale expands to fit both orbits
  • State stored on refs._scaleMode

Hyperbolic orbit scale:

  • For escape orbits (e ≥ 1), the scale reference is max(rPe × 4, rShip × 1.3) where rShip is the ship’s current distance from the reference body
  • This ensures the ship dot is always visible within the diagram, even when far from periapsis on a hyperbolic trajectory
  • rShip is passed through updateOrbitDiagram() from state.referenceBody.distance

Hyperbolic path extent (computeHypNuMax):

  • The SVG path for hyperbolic orbits extends from −nuMax to +nuMax (symmetric about periapsis)
  • nuMax is computed by computeHypNuMax(e, p, nuShip, maxDrawR) in orbitDiagramCalc.js:
    1. Start at acos(−1/e) − 0.001 (tight asymptote margin)
    2. Cap to where r(ν) = maxDrawR if maxDrawR > 0: nuCap = acos((p/maxDrawR − 1)/e), take min(nuMax, nuCap)
    3. Extend to include ship: nuMax = max(nuMax, min(|nuShip| + 0.02, asymptote − 0.001))
  • maxDrawR = 2 × MAX_ORBIT_R / scale (twice the fitting radius in world units), so path arms extend to the diagram edges but don’t go to infinity
  • This ensures the orbit path always reaches the ship’s current position, even for high-eccentricity hyperbolas where the ship is near the asymptote

SOI intersection detection (findChildSOISegments):

When child SOIs are visible on the orbit diagram, findChildSOISegments() in orbitDiagramCalc.js detects where the ship’s Keplerian trajectory passes through each child body’s sphere of influence. The algorithm:

  1. For each child body, the caller pre-projects the child’s position to perifocal (ê, q̂) coordinates using the heading basis vectors and argument of periapsis.
  2. The out-of-plane distance h is computed via the orbit normal ĥ = eN × eP. If |h| ≥ r_SOI, the orbit plane doesn’t intersect the SOI sphere and the child is skipped.
  3. The effective in-plane SOI radius is sqrt(r_SOI² − h²).
  4. The orbit is sampled at 720 points (elliptical: one full orbit 0→2π; hyperbolic: −nuMax→+nuMax).
  5. At each sample, the distance from the orbit point (r·cos ν, r·sin ν) to the child’s perifocal-frame center is computed.
  6. Transitions are detected: outside→inside = entry, inside→outside = exit.
  7. Each crossing is refined via 12-iteration bisection to sub-degree accuracy.
  8. Returns an array of { childName, segments: [{ nuEntry, nuExit }] } per child.

Rendering: Each intersection segment is drawn as a cyan (#0ff) polyline overlaid on the orbit path, with entry/exit markers (circles r=2.5) and labels. All SOI segment SVG elements are managed in a _soiSegmentGroup (same pattern as _childSOIGroup).

Hide conditions (all target elements hidden when any apply):

  • No target selected
  • Target is the reference body itself
  • Target type is 'lagrange' (no velocity data for orbit computation)
  • Target orbits a different body than the player (different SOI) — prevents drawing a meaningless orbit computed against the wrong reference body. Target dot still shows for directional reference.

Out-of-plane threshold:

  • Compute relative inclination: cosRelInc = shipĥ · targetĥ
  • If |cosRelInc| < cos(45°) ≈ 0.707, hide target orbit ellipse (show dot only) — projection is too distorted

Interactive tooltips:

  • HTML <div> overlay positioned absolutely within the window
  • Shown on hover over any labeled SVG element
  • Each hoverable element has data-tooltip attribute with name and description
  • Tooltip shows element name, current value, and brief description

Window behavior:

  • Draggable by title bar
  • Title bar shows “Orbit — {body name}”
  • Updates in real-time when visible (on each state update tick)
  • Available in both cockpit view and map view
  • Menu item dispatches event; both views listen, only active view responds

Ship Systems Dashboard

A floating window with an SVG-rendered ship systems dashboard showing a navball attitude indicator, fuel/delta-v gauge, thrust indicator, position/velocity readout, angular velocity, and reaction wheel saturation. Available in both cockpit and map views.

Setting Value
Toggle key U
Menu location View > Ship Systems
Default state Hidden
Draggable Yes (via title bar)
Position top: 48px; left: 350px
Width 340px

SVG Layout (viewBox 0 0 320 440):

The SVG contains six sections stacked vertically:

  1. Navball (y=0–165): Attitude indicator with horizon, pitch ladder, and orbital markers
  2. Fuel/Thrust gauges (y=165–270): Vertical bars with numeric readouts; max thrust label above thrust gauge
  3. Alt/Spd (y=270–295): Altitude and speed readouts
  4. Rotation rates (y=295–345): Graphical bidirectional bars (P/Y/R, ±10°/s range). Interpolated at 60 FPS via LERP between prevAngularVelocity and angularVelocity.
  5. Reaction wheels (y=350–400): Graphical unidirectional bars (P/Y/R, 0–100%). Interpolated at 60 FPS via LERP between prevWheelSaturation and wheelSaturation.
  6. Proximity alert (y=405–435): Nearest station within 50 km

Ship name (editable):

Above the SVG content, an inline text input displays the ship’s current name (default = player username). A “Save” button appears when the input value differs from the current name. On save, sends ship_rename WebSocket message. The window title also reflects the current ship name (e.g., “Ship Systems — MyShipName”). Ship names update in real-time for all connected players — both cockpit view ship indicators and map view labels refresh each tick when a name changes.

Ship lighting toggles:

Below the SVG, two checkbox toggles (using the .display-toggle CSS pattern) control own-ship lighting:

Toggle Default Effect
Nav Lights On Enables/disables the blinking navigation lights (red port, green starboard) on the player’s own ship. Other players’ ships are unaffected.
Headlight On Shows/hides the forward headlight glow on the player’s own ship. Only visible on ship classes that have a headlight mesh (e.g., fast_frigate).

Both settings are persisted to localStorage via navLights and headlight keys. The toggles only affect the player’s own ship — other players’ nav lights and headlights are always visible.

Navball (simplified 2D projection):

Clipped circle (r=65, center 160,80) with:

  1. Sky/ground halves — sky #112244, ground #443322, split by horizon line
  2. Horizon line — moves vertically by pitch, tilts by roll (relative to local vertical from reference body)
  3. Pitch ladder — horizontal lines at ±10°, ±20°, ±30° intervals
  4. Attitude markers — prograde/retrograde/normal/antinormal/radial/antiradial projected onto navball circle:
    • Compute direction in ICRF, transform to ship body frame via inverse quaternion
    • Project: px = CX + bodyRight × R, py = CY - bodyUp × R
    • Only show if forward component > 0 (front hemisphere)
Marker Symbol Color
Prograde Circle + dot #00ff00
Retrograde Circle + X #00ff00
Normal Triangle up #ff00ff
Antinormal Triangle down #ff00ff
Radial out Square #00ffff
Radial in Square + X #00ffff
Target Prograde Diamond #ffff00
Target Retrograde Diamond + X #ffff00
Target Circle + crosshair #ff8800
Anti-Target Circle + X #ff8800
  1. Attitude mode label — text below navball showing current mode (e.g., “PROGRADE”, “HOLD”)

Pitch/roll from quaternion (local-vertical reference):

Local vertical = normalized(body_pos - ship_pos) → direction toward body
Ship forward/right/up = quaternion × basis vectors in Three.js
Pitch = asin(forward · localUp)
Roll = atan2(right · localUp, up · localUp)

Per-frame navball interpolation:

The navball updates per-frame (not just on server ticks) using SLERP-interpolated attitude, matching the 3D ship mesh interpolation. The updateNavball function is exported from shipSystems.js and called independently from animate() in both cockpit and map views.

  • Each view caches the orbital data (reference body, relative velocity) computed during the last tick-rate _updateShipSystemsData() call
  • In animate(), the own ship’s attitude is SLERP’d between prevAttitude and attitude using timeSinceUpdate = now - ss.updateTime, clamped to [0, 1] — identical to the 3D mesh interpolation in extrapolatePositions()
  • The interpolated attitude is passed to updateNavball(refs, shipData, orbitalData) where shipData.attitude contains the SLERP’d quaternion
  • Fuel gauges, thrust, delta-v, and other numeric readouts continue to update only at tick rate via updateShipSystems()

Fuel gauge:

  • Vertical bar (20×85px), drains top-to-bottom
  • Color: green (>50%), orange (20-50%), red (<20%)
  • Labeled “FUEL” below bar

Thrust gauge:

  • Vertical bar (20×85px), fills bottom-to-top
  • Color: orange (#ff8800)
  • Labeled “THR” below bar
  • Max thrust label centered above thrust gauge (font 9px, color #888), shows ship class max thrust (e.g., “600kN” for fast frigate, “400kN” for cargo hauler)

Numeric readouts (right of gauges):

Field Computation Format
Fuel fuel / fuel_capacity × 100 Percentage
Delta-v ISP × g₀ × ln((DRY_MASS + fuel) / DRY_MASS) m/s with commas
Burn time fuel / (THRUST × thrust_level / (ISP × g₀)) via formatRemainingBurnTime() Xm Xs or Xh Xm
Accel THRUST × thrust_level / (DRY_MASS + fuel) m/s²
Mass DRY_MASS + fuel kg with commas
TWR (thrust_level × THRUST) / (mass × g_local) where g_local = G × body_mass / distance² dimensionless, 2 decimal places; when thrust is zero

Constants: ISP=20,000 s, g₀=9.80665 m/s², DRY_MASS=20,000 kg, THRUST=500,000 N, FUEL_CAPACITY=30,000 kg.

Alt/Spd readout:

Field Source Format
Alt Altitude above reference body km or Mm
Spd Speed relative to reference body m/s or km/s

Rotation rate bars (bidirectional, ±10°/s):

Section header “ROTATION” at y=302 (font 9px, color #888). Three bar rows (P, Y, R) starting at y=312, spacing 14px:

Element Position/Size Style
Axis label (P/Y/R) x=12, font-size 10 color #888
Bar background x=24, w=200, h=10 fill #1a1a1a, stroke #333
Center line x=124 (midpoint) stroke #444
Bar fill From center, extends left/right fill #6cf, width = |rate| / 10 × 100px (max 100px)
Value text x=230, font-size 10 color #6cf, format “±X.X°/s”

Data mapping (physics body frame → display):

  • P = angular_velocity.x (pitch)
  • Y = angular_velocity.y (yaw)
  • R = angular_velocity.z (roll)

Reaction wheel bars (unidirectional, 0–100%):

Section header “REACTION WHEELS” at y=357 (font 9px, color #888). Three bar rows (P, Y, R) starting at y=367, spacing 14px:

Element Position/Size Style
Axis label (P/Y/R) x=12, font-size 10 color #888
Bar background x=24, w=200, h=10 fill #1a1a1a, stroke #333
Bar fill From left, width = saturation × 200px Color by level (see below)
Value text x=230, font-size 10 color matching fill, format “XX%”

Fill color thresholds:

  • 0–50%: green #00cc00
  • 50–80%: yellow #cc8800
  • 80–100%: red #cc3333

Data mapping:

  • P = wheel_saturation.x (pitch)
  • Y = wheel_saturation.y (yaw)
  • R = wheel_saturation.z (roll)

Proximity alert:

Displays the nearest station within 50 km, including distance and closing rate.

Field Source Format
Station name Nearest station within 50 km Text
Distance Euclidean distance ship↔station m, km, or Mm (via formatDistance)
Closing rate dot(relPos, relVel) / |relPos| ±X.X m/s or ±X.X km/s
  • relPos = station.pos - ship.pos, relVel = station.vel - ship.vel
  • Negative closing rate = approaching, positive = receding
  • Label PROX: in gray (#888) at left
  • Within 50 km: station name + distance + closing rate in red (#f44)
  • Nothing nearby: CLEAR in dim green (#4a4)
  • Separator line at y=405

Interactive tooltips:

  • Same pattern as Orbit Diagram: HTML <div> overlay positioned absolutely
  • Shown on hover over any labeled SVG element with data-tooltip attribute
  • Tooltip shows element name and brief description

Window behavior:

  • Draggable by title bar
  • Updates in real-time when visible (on each state update tick)
  • Available in both cockpit view and map view
  • Menu item dispatches event; both views listen, only active view responds

Keyboard Controls Window

A floating window displaying keyboard shortcut reference. Available in both cockpit and map views.

Setting Value
Toggle key ? (Shift+Slash)
Menu location Help > Keyboard Controls
Default state Hidden
Draggable Yes (via title bar)

Window contents:

Line Text
1 WASD - Pitch/Yaw | QE - Roll | Z - Toggle RCS Translation
2 +/- - Thrust (10% steps) | H - Hold
3 P - Prograde | Shift+P - Retrograde
4 J/Shift+J - Normal/Antinormal | K/Shift+K - Radial/Antiradial
5 L/Shift+L - Local Horiz/Vert
6 X/Shift+X - Cycle Target | Esc - Deselect | G - Target | Shift+G - Anti-Target
7 T - Tracers | V - Velocity | O - Spin | N - Orbit | B - Markers | U - Status
8 R - Spawn | Shift+R - Quick Reset | F - Refuel | M - Map

Window behavior:

  • Draggable by clicking and dragging the title bar
  • Position remembered during session
  • Click X button or press ? to close
  • Default position: bottom-left (above status bar)

Ship Specs Window

A tabbed floating window displaying static ship class parameters, live performance metrics, and an SVG layout diagram. Available in both cockpit and map views.

Setting Value
Element ID ship-specs-window
Toggle key Y
Menu location View > Ship Specs
Default state Hidden
Draggable Yes, with position persistence (windowPositions.shipSpecs)
Persistence shipSpecs and shipSpecsActiveTab settings in localStorage

Tabs:

The window has a tab bar with the active tab persisted in shipSpecsActiveTab setting.

Specs tab (static class parameters):

Section Readouts
Propulsion Thrust, Isp, Fuel Capacity, Dry Mass, Full Mass, Max Δv
Attitude Control Wheel Torque, Wheel Capacity, RCS Torque, RCS Linear, Controller ωn
Inertia (Dry) Roll (X), Pitch (Y), Yaw (Z) moments in kg·m²

Each readout includes a bar chart showing the value relative to the maximum across all ship classes, color-coded by section (cyan for propulsion, green for attitude, orange for inertia).

Performance tab (live, updated each tick):

Section Readouts
Propulsion Thrust Level, Current Thrust, Acceleration, Mass Flow
Mass Current Mass, Fuel, Burn Time
Performance Δv Remaining, TWR (local), Max Accel (empty), Max Accel (full)
Inertia (Current) X (Roll), Y (Pitch), Z (Yaw) — includes fuel contribution

Performance values update when the window is visible and ship data is available.

Layout tab (SVG diagram):

SVG rendering of the ship’s physical layout showing side view and top view with:

  • Structural components (hull, command module, engineering section)
  • Engine bell position and thrust vector
  • RCS pod positions (green squares)
  • Dimension lines with measurements
  • Component labels

Different diagrams per ship class (cargo hauler, fast frigate).

Altitude readouts (below tabs):

Readout Description
ASL Altitude above sea level (distance from body center minus radius)
AGL Above Ground Level — prefers server-authoritative value, falls back to client terrain query

About Window

A floating window displaying service version information. Available in both cockpit and map views.

Setting Value
Element ID about-window
Menu location Help > About
Default state Hidden
Draggable Yes (not persisted — transient window)

Window contents:

Field Source
Web Client __APP_VERSION__ build-time constant, fallback to state.versions.web_client
API Gateway state.versions.api_gateway (from welcome WebSocket message)
Physics state.versions.physics
Tick Engine state.versions.tick_engine

Version strings are populated on toggle-open from current state. The window is not persisted across sessions (transient).

Settings Window

A tabbed floating window organizing display and audio settings into separate tabs. Available in both cockpit and map views.

Setting Value
Toggle key Shift+D
Menu location Game > Settings
Default state Hidden
Draggable Yes (via title bar)

Tabs:

The window has a tab bar between the header and content area. The active tab is persisted to localStorage and restored on reopen.

Display tab (default):

Option Shortcut Description
Tracers T Orbital trajectory lines
Velocity Vector V Velocity direction arrow
Angular Velocity O Rotation axis arrow
Wireframe Body wireframe overlays
Orbital Path N Predicted orbit line and event markers
Crosshair C Cockpit crosshair / map ship ring

Below the general display toggles, a Markers section (separated by a thin horizontal rule) provides per-category visibility checkboxes:

Option Default Description
Bodies On Celestial body name and distance labels
Ships On Other players’ ship labels
Stations On Space station labels
Lagrange Points On L-point labels
Jump Gates On Jump gate labels

The B keyboard shortcut and Body Markers menu item act as a master toggle: if any category is off, all are turned on; if all are on, all are turned off.

Read-only Zoom distance field (m, km, Mm, Gm) displayed below the markers section.

Audio tab:

Option Shortcut Description
Audio Enable/disable game audio
Volume Master volume slider (0-100%)

Window behavior:

  • Checkboxes reflect current toggle states in real-time
  • Clicking a checkbox toggles the option (same effect as keyboard shortcut)
  • Keyboard shortcuts (T, V, O, N, B, C) still work independently
  • Active tab persists across window close/reopen and page reloads
  • Draggable by title bar
  • Position remembered during session
  • Click X button or press Shift+D to close

Target Selection

Players can designate multiple objects (bodies, ships, stations, Lagrange points) as navigation targets simultaneously. One target is “focused” at a time for dashboard display and attitude control. A highlight cursor allows browsing nearby objects before adding them to the target set.

Selectable object types:

Type Examples
body Sun, Earth, Luna, Mars
ship Other player ships
station Space stations
jumpgate Jump gates
lagrange Sun–Earth L1, Earth–Luna L2

Automation rendezvous target selection:

The automation rule form uses a segmented target selection widget instead of a flat dropdown. The widget consists of:

  1. “Use current target” button — Sets the rendezvous target to the player’s currently focused target. Disabled (dimmed) when no compatible target is focused. Compatible types: ship, station, jumpgate, lagrange (not body). Shows the focused target’s name when available.

  2. Segmented control — Four category buttons: Stations, Gates, Ships, Lagrange. Clicking a segment shows the corresponding picker and hides others. Active segment is visually highlighted.

  3. Per-category pickers:

    • Stations: <select> listing all stations sorted by name, values station:{id}
    • Gates: <select> listing all jumpgates sorted by name, values jumpgate:{id}
    • Ships: <select> listing other players’ ships sorted by name, values ship:{id}
    • Lagrange: Two-level picker — system <select> listing all 28+ parent–child body pairs from BODY_PARENTS (e.g., “Sun–Earth”, “Earth–Luna”) sorted by label, plus a point <select> with L1–L5. Combined value: lagrange:{Parent}-{Child}-{Ln} (e.g., lagrange:Sun-Earth-L4).

The widget synchronizes with the focused target: when a target is focused in the HUD, the “Use current target” button updates to show the target name and becomes enabled if the target type is compatible. On initial form open, if a compatible target is focused, it auto-populates. When editing an existing rule, the widget pre-selects the correct segment and picker value.

State model:

Field Type Description
state.targets Map<string, {id, type, role}> All active targets. Key is composite "type:id". Role is 'player' | 'automation'.
state.focusedTargetKey string | null Composite key of the target shown in dashboard and used for attitude commands
state.highlightCursorKey string | null Composite key of the TAB-cycling cursor (transient, not persisted)
state.targetedBy array (unchanged) [{player_id, ship_id, name}] — players targeting our ship

Backward-compatible getters state.targetId and state.targetType read from the focused target entry in the Map.

Target entry structure:

Field Type Description
id string Entity ID (body name, ship_id, station_id, jumpgate_id, lagrange key)
type string 'body' | 'ship' | 'station' | 'jumpgate' | 'lagrange'
role string 'player' (user-selected) or 'automation' (injected by maneuver system)

Composite key: "type:id" (e.g., "body:Earth", "ship:uuid"). Used as Map key for O(1) lookup.

Selection methods (cockpit view):

Method Action
Click indicator Toggle target in/out of target set (add if absent, remove if present)
TAB Highlight cursor: cycle forward through nearby objects (sorted by distance)
Shift+TAB Highlight cursor: cycle backward
Enter Toggle highlighted object into/out of target set
X Cycle focus forward within target set (switches dashboard)
Shift+X Cycle focus backward within target set
Escape Remove focused target from set (shifts focus to next)

Selection methods (map view):

Method Action
Click indicator Toggle target in/out of target set; fly camera to newly added target

Visual feedback:

Visual Description
.selected CSS class Applied to all targets in the set (pulse animation)
.focused CSS class Additional class on the focused target (brighter highlight)
.highlight-cursor CSS class White glow on TAB-cycling cursor (not in target set yet)

Target roles and bracket colors (cockpit view):

Role Bracket color Off-screen chevron
player Green #00ff88 Green .offscreen-chevron
automation Orange #ff8800 Orange .offscreen-chevron-automation

Pure logic functions (targetCalc.js):

Function Description
targetKey(id, type) Returns composite key "type:id"
addTarget(targets, id, type, role) Adds entry to Map, returns key
removeTarget(targets, key) Removes entry from Map
toggleTarget(targets, id, type) Adds (player role) if absent, removes if present; returns {key, added}
hasTarget(targets, id, type) Returns boolean
cycleFocus(targets, currentKey, direction) Cycles within target set, returns new key
removeByRole(targets, role) Removes all entries with given role

Existing functions distance3D, buildSortedTargets, cycleTargetIndex are unchanged.

Selection is shared between cockpit and map views. Adding a target in map view persists when switching to cockpit view, and vice versa.

Target dashboard (floating, both views):

A floating draggable window with data readouts for the focused target. In cockpit view, it additionally includes a 3D picture-in-picture view of the target, rendered by a second WebGLRenderer sharing the cockpit scene. The camera faces the target from the ship’s direction (what you’d see approaching it). Data readouts are displayed below the 3D view.

Setting Value
Element ID target-dashboard-window
Default position Top-right (top: 48px; right: 20px)
Width 300px
Canvas size 300×200
Style floating-window class (draggable header, close button)
Draggable Yes, via makeDraggable() with position persistence (windowPositions.targetDashboard)
Close button Hides window, sets manual-close flag; selecting a new target reopens
Toggle Shift+T keyboard shortcut, View menu entry
Visibility Auto-shows when target selected (if not manually closed); hides when all targets removed. Automation-injected targets (from maneuver_status) also trigger auto-show.
Persistence targetDashboard setting in localStorage
Renderer Lazy-initialized THREE.WebGLRenderer on first show (no GPU cost if never used)
3D rendering Only when dashboard visible + focused target exists; cockpit view animate loop only
Readout updates Both views: _updateTargetDashboardReadouts() called from animate() when dashboard visible + focused target exists

Dashboard navigation:

Element Description
< / > arrows Flanking the title in dashboard header; click to cycle focus prev/next within target set
Compact target list Below readouts; shows all targets with role-colored dot + name + distance. Click to focus.
Readout Description
Dist Distance from ship to focused target
RelV Relative velocity magnitude
Clos Closing speed (green = approaching, red = receding)
ETA Distance / closing speed (shown only when approaching)

Per-frame readout interpolation: All readouts and camera direction use Verlet-interpolated positions (_clientPos/_clientVel) instead of raw server tick data. This matches the 3D canvas interpolation and prevents jerky once-per-second snapping of the numeric values.

Camera positioning:

  • Position: target mesh + normalized(target→ship) × viewDistance
  • viewDistance: bodies = radius × 3, ships/stations = fixed distance based on visual scale
  • logarithmicDepthBuffer: true to match main renderer
  • FOV: 45°, near: 1e-9, far: 1e12 × SCALE

Off-screen direction indicators (cockpit view):

Each target in the set gets its own off-screen chevron when not visible on screen.

Setting Value
Element Pool of fixed-position divs with CSS chevrons
Position Screen edge, rotated to point toward target’s projected position
z-index 600 (above CSS2D overlay)
Visibility Shown per-target when off-screen
Update rate Every frame
Color Green for player targets, orange for automation targets

The indicator projects the target’s 3D position to screen coordinates. If the target is behind the camera or outside the viewport, the chevron is placed at the nearest screen edge and rotated to point toward the target.

Position extrapolation: The target’s position must be extrapolated using its velocity (position + velocity * dt) before projection to screen coordinates. This matches the extrapolation applied to all 3D meshes in the animate loop and prevents the indicator from stuttering at server tick rate while the camera moves smoothly at render framerate. _getTargetVelocity() provides the velocity for body, ship, and station targets. Lagrange point targets have no velocity and use their current position directly (acceptable — they move slowly relative to the viewport).

Targeting brackets (cockpit view only):

Each target in the set gets a CSS2DObject bracket at its 3D position. Brackets are managed as a dynamic pool (following the _targetedByBrackets pattern).

Setting Value
Size 40×40px fixed
Player color #00ff88 (green) — .target-bracket-player
Automation color #ff8800 (orange) — .target-bracket-automation
Focused highlight .target-bracket-focused class adds brightness(1.5) filter
Corner size 10×10px, 2px border
Implementation Dynamic pool of CSS2DObject instances, allocated on demand
Visibility One bracket per target in the set; hidden when target removed
Update Every frame in animate(), positioned at each target’s extrapolated 3D world position

No brackets in map view — the existing .selected pulse animation is sufficient.

Settings persistence:

Setting Type Description
targets array [{id, type}] — only player-role targets persisted (not automation)
focusedTargetKey string | null Composite key of focused target

Migration: if old targetId/targetType found in localStorage, convert to new format on load.

Server communication:

The target_select WebSocket message format is unchanged. The client sends target_select for the focused target only (if it’s a ship). The multi-target set is entirely client-local state. On reconnect, the focused ship target is re-sent to restore server-side targeting state.

Server-relayed targeting notification:

When a player selects a target, a target_select message is sent to the server via sendTargetSelect(). The server relays targeting information between ships so players know when they are being targeted.

Trigger Message sent
_selectTarget() in cockpit view sendTargetSelect(id, type)
_deselectTarget() in cockpit view sendTargetSelect(null, null)
_selectBody() / _selectShip() / _selectStation() in map view sendTargetSelect(id, type)
_clearSelection() in map view sendTargetSelect(null, null)
WebSocket reconnect Re-send current target after auth

Targeted-by brackets (cockpit view only):

CSS2DObject brackets rendered at the 3D position of each ship that is targeting the player. Visually distinct from the outbound targeting bracket so players can instantly distinguish “I’m targeting them” (green square) from “they’re targeting me” (red diamond).

Setting Value
Size 30×30px fixed
Color #ff4444 (red)
Rotation 45° (diamond orientation)
Corner size 8×8px, 2px border
Pool size 4 (max simultaneous brackets)
Implementation Pool of CSS2DObjects, each repositioned per frame
Visibility One bracket per targeter ship (from state.targetedBy), hidden when unused
Update Every frame in animate(), positioned at each targeter ship’s 3D world position

Each bracket is positioned at the world position of the targeting ship’s mesh (this.ships[entry.ship_id]). If the ship mesh is not loaded (e.g., out of range), that bracket is hidden. Unused pool entries beyond state.targetedBy.length are hidden. An audio alert (playTargetedAlert()) plays on the first targeter (0→1+ transition).

Targeted-by off-screen indicators (cockpit view only):

When a targeter ship is off-screen, a red chevron is shown at the screen edge pointing toward the targeter. Uses the same screen-projection and edge-intersection algorithm as the selected-target off-screen indicator (green chevron), but colored red (#ff4444) and pooled (one per targeter, pool of 4).

Setting Value
Pool size 4 (matches targeted-by bracket pool)
Color #ff4444 (red)
Shape Chevron (same triangle geometry as target off-screen indicator)
Positioning Fixed at screen edge, rotated to point toward targeter
Visibility Shown only when targeter ship is off-screen; hidden when on-screen (CSS2D bracket handles it)
z-index 600 (same as target off-screen indicator)

A shared helper _computeOffScreenPosition(worldPos) computes the screen projection, on-screen check, edge position, and rotation angle. Used by both the selected-target off-screen indicator and the targeted-by off-screen indicators to avoid code duplication.

Target Lock Camera

A toggle that locks the camera view direction onto the focused target, continuously tracking it as ship and target move. Supported in both cockpit and map views; the lock persists across view switches.

State model:

Field Type Description
state.viewLockTargetKey string | null Composite key "type:id" of locked target, null when unlocked

Key binding: Backquote (`) toggles the lock on/off. If unlocked and state.focusedTargetKey exists, engaging stores the focused target key. If already locked, disengages.

Cockpit view behavior:

  • Per-frame: if state.viewLockTargetKey is set, get target world position via _getTargetWorldPos(id, type). Supported types: body, ship, station, jumpgate, lagrange, facility. If target unavailable (returns null), disengage.
  • Compute look-at quaternion using a temporary camera at the same position, looking at the target.
  • Smooth transition (first 0.5s): SLERP from the camera quaternion at engage time to the computed look-at quaternion, using cubic ease-in-out (t < 0.5 ? 4t³ : 1 - (-2t+2)³/2).
  • After transition: set camera.quaternion directly from lookAt each frame.
  • Suppress OrbitControls rotation while locked (controls.enableRotate = false); re-enable on disengage. Zoom (scroll wheel) remains functional via controls.enableZoom.

Map view behavior:

  • Per-frame: if state.viewLockTargetKey is set, get target physics position via _getTargetPosition(id, type) (supports body, ship, station, jumpgate, lagrange, facility), convert to map scale, and set controls.target to track the target.
  • On first engage: smooth transition (0.5s cubic ease-in-out) from current controls.target to target position.
  • After transition: controls.target tracks target each frame. Camera can still orbit and zoom freely around the tracked point.

Disengage triggers:

Trigger Description
Backquote press Toggle off
Mouse drag (pointerdown + pointermove) Manual camera override disengages lock
Target removed (Escape) removeTarget clears viewLockTargetKey if it matches removed key
Target data unavailable _getTargetWorldPos / _getTargetPosition returns null

Focus change while locked: When X/Shift+X cycles focus and state.viewLockTargetKey is set, update viewLockTargetKey to the new state.focusedTargetKey and restart the smooth transition.

Visual indicator: When state.viewLockTargetKey === state.focusedTargetKey, the targeting bracket color changes to bright cyan (#00ffff) and “LOCKED” text is appended near the brackets, providing clear visual feedback that the camera is locked on target.

Pure logic helper (targetCalc.js):

Function Description
clearViewLockIfRemoved(stateObj, removedKey) Clears stateObj.viewLockTargetKey if it matches removedKey

Controls help: ` — Target Lock (added to keyboard controls window).

Status Bar

A fixed status bar at the bottom-left of the screen showing connection health, latency, tick rate, and online player count.

Setting Value
Position Fixed, bottom-left (below controls help)
Default state Hidden (shown after login)
Visibility Shown in cockpit view, hidden in map view
Update rate 4 Hz (every 250ms) — throttled to avoid unnecessary DOM reflows

Status bar contents:

Element Description Format
Server name Configurable server instance name (from config.server_name) Text, color #a8a (soft purple)
Player name Current player’s username Text
Ship name Name of the currently piloted ship Text, color #fc6 (warm amber)
Connection dot Colored circle indicating connection state 8px circle
Status text Connection state label “Connected”, “Reconnecting”, “Disconnected”
Latency Round-trip time to server “42 ms”, “– ms” if unknown
Tick rate Current server tick rate “1.0 Hz”
Players online Number of connected players (from state.players_online) “3 online”
Game time Current simulation date/time (shortened) “2025-01-15 14:32:07”

Connection dot colors:

State Color Hex
Connected Green #0f0
Reconnecting Yellow #ff0
Disconnected Red #f66

Latency color coding:

Range Color
< 100 ms Green (#0f0)
100-300 ms Yellow (#ff0)
> 300 ms Red (#f66)

Latency measurement:

  • Client sends ping message with Date.now() timestamp every 30 seconds
  • Server echoes back as pong with same timestamp
  • RTT = Date.now() - pong.timestamp
  • Displayed value uses exponential moving average: ema = prev * 0.7 + new * 0.3 (first sample used directly)

Player count:

  • Initialized from first state message: ships.length + 1 (other ships + self)
  • Incremented on player_joined message
  • Decremented on player_left message (minimum 0)

Styling:

  • Background: rgba(0,0,0,0.7), padding 8px 15px, border-radius 5px
  • Font: monospace, 12px, color #888
  • Separators: | character, color #444, margin 0 8px

Pause Overlay

A full-screen semi-transparent overlay shown when the game is paused.

Setting Value
Trigger game_paused message or welcome.config.paused === true
Dismiss game_resumed message or receipt of any state message
z-index 900

Visual:

  • Full-screen fixed overlay
  • Background: rgba(0,0,0,0.5)
  • Centered text: “GAME PAUSED”
  • Text: 36px, bold, white, letter-spacing 8px, text-shadow glow

Ship Class Selection (Respawn UI)

A modal overlay shown during respawn that lets the player choose their ship class.

When shown:

  • On respawn via the Spawn Location Selector (after selecting a body, before spawning)
  • On login when ship is missing (needs re-spawn)
  • NOT shown on first registration (new players get Fast Frigate automatically)
  • NOT shown for Shift+R quick reset (uses current ship class)
  • NOT shown for fuel-only refuel (F key)

Layout:

Setting Value
Type Modal overlay (blocks interaction with game)
Background rgba(0,0,0,0.7)
z-index 950 (above pause overlay)
Width 600px centered

Ship class cards:

Each available ship class is displayed as a selectable card:

Element Description
Class name Large heading (e.g., “Fast Frigate”, “Cargo Hauler”)
Silhouette SVG or canvas outline of the ship shape
Key stats Mass, Thrust, Delta-v, Max acceleration
Selection indicator Highlighted border when selected

Card stats format:

Stat Fast Frigate Cargo Hauler
Mass 8t / 23t 100t / 160t
Thrust 600 kN 400 kN
Delta-v 207 km/s 69 km/s
Max accel 26.1 m/s² 2.5 m/s²

Behavior:

  • Fast Frigate card is pre-selected (default)
  • Clicking a card selects it (highlighted border, color: #4488ff)
  • “Spawn” button at bottom confirms selection
  • ESC or X button cancels (returns to cockpit without respawning)
  • Selection sends ship_class field with the spawn request

Spawn Location Selector

A floating window that lets the player choose which body to spawn around when resetting. Available in both cockpit and map views.

Setting Value
Toggle key R
Quick reset Shift+R (Earth LEO, no window)
Menu location Ship > Spawn Location
Default state Hidden
Draggable Yes (via title bar)

Window contents:

Hierarchical tree of celestial bodies in solar order:

  • Planets listed in order from Sun (Mercury → Neptune)
  • Moons indented beneath their parent planet
  • Each row shows: colored indicator dot + body name + curated orbit altitude
  • Clicking a body resets the ship to orbit that body and closes the window
  • Sun is not listed (no stable low orbit)
  • Stations are grouped under their parent body, indented beneath the body’s moons
  • Each station row shows: diamond icon (CSS class tree-station-icon) + station name + altitude
  • Clicking a station spawns the ship near the station (1 km offset, matching orbital velocity)

Indicator dot colors match the system browser / map view palette:

  • Rocky planets: tan (#cc9966)
  • Gas giants: orange (#ff9944)
  • Ice giants: cyan (#66ccff)
  • Moons: gray (#aaaaaa)

Window behavior:

  • R key toggles visibility (show/hide)
  • ESC or X button closes without reset
  • Shift+R bypasses the window and immediately resets to Earth LEO
  • Body tree is rebuilt each time the window opens

Spawn location restrictions (role-based):

Normal players can only spawn at Gateway Station (Sol system). This applies to both ship reset (respawn) and fleet spawn (new ship creation). Admins can spawn at any location.

Role Reset (respawn) Fleet spawn
Normal player Gateway Station only Gateway Station in Sol only
Admin Any body, station, or jump gate Any station in any system

Enforcement is at two layers:

  1. Backend (authoritative): The API gateway validates spawn requests before forwarding to physics/players services. Non-admin requests to spawn at locations other than Gateway Station receive error code E040 (“Spawn restricted to Gateway Station”).
  2. Frontend (UX): The spawn selector tree and fleet spawn dialog filter available options based on state.isAdmin:
    • Non-admin spawn selector: shows only Gateway Station (no bodies, jump gates, or other stations)
    • Non-admin fleet spawn: system selector hidden (forced to Sol), spawn selector shows only Gateway Station
    • Admin: full spawn tree and system selector (current behavior)

Speed display format:

Speed is shown relative to the nearest body, with the reference body indicated:

Speed: 7,823 m/s (Earth)

Reference body calculation:

The reference body for Speed, Position, and Altitude uses SOI (Sphere of Influence) containment to select the most specific body:

  1. For each non-star body, compute its Hill sphere (SOI) radius relative to its parent:
    r_SOI = d × (m_body / (3 × m_parent))^(1/3)
    

    where d is the distance from the body to its parent (planets → Sun, moons → planet).

  2. If the ship is within a body’s SOI (distance(ship, body) < r_SOI), that body is a candidate.
  3. Among all candidates, select the one with the smallest SOI (most specific / deepest in hierarchy).
  4. Fallback to M / r² if no SOI contains the ship (Sun is always the implicit fallback).
candidates = [body for body in bodies if body.type != 'star'
              and distance(ship, body) < hill_sphere(body, parent(body))]
reference_body = min(candidates, key = hill_sphere(body, parent(body)))
relative_velocity = ship_velocity - reference_body_velocity
speed = magnitude(relative_velocity)
altitude = distance(ship_position, reference_body.position) - reference_body.radius

This prefers the most specific body in the hierarchy. For example, a ship orbiting Mimas (within its Hill sphere) references Mimas rather than Saturn, giving physically meaningful orbital elements and prograde/retrograde directions. A ship that escapes Mimas’s SOI transitions to Saturn as its reference body.

Orbital elements calculation:

Periapsis and apoapsis are calculated from orbital mechanics equations:

// Gravitational parameter
μ = G × body.mass

// Position and velocity relative to body
r = ship_position - body.position  // vector
v = ship_velocity - body.velocity  // vector

// Specific orbital energy
ε = |v|²/2 - μ/|r|

// Specific angular momentum magnitude
h = |r × v|

// Eccentricity
e = (1 + 2εh²/μ²)

// For elliptical orbits (e < 1):
a = -μ/(2ε)                    // Semi-major axis
r_pe = a × (1 - e)             // Periapsis distance from center
r_ap = a × (1 + e)             // Apoapsis distance from center
periapsis_altitude = r_pe - body.radius
apoapsis_altitude = r_ap - body.radius

// For hyperbolic/parabolic (e ≥ 1):
r_pe = h²/μ × 1/(1+e)          // Periapsis still exists
periapsis_altitude = r_pe - body.radius
apoapsis = "Escape"            // No apoapsis

Edge cases:

Condition Periapsis Display Apoapsis Display
Elliptical orbit (e < 1) Altitude in km/Mm Altitude in km/Mm
Escape trajectory (e ≥ 1) Altitude in km/Mm “Escape”
Suborbital (periapsis < 0) “Impact” Altitude in km/Mm
No nearby body “N/A” “N/A”

Altitude units:

Range Unit
< 10,000 km km (no decimals)
≥ 10,000 km Mm (1 decimal)

Inclination calculation:

Inclination is the unsigned angle (0°–180°) between the orbital angular momentum vector and the body’s north pole, following standard Keplerian convention:

// Angular momentum vector (orbital plane normal)
h = r × v  // cross product

// Body's north pole direction in ecliptic coordinates (from BODY_SPIN_AXES)
north_pole = getBodySpinAxis(body.name)

// Inclination (0° to 180°, always unsigned)
cos_i = (h · north_pole) / (|h| * |north_pole|)
inclination = acos(clamp(cos_i, -1, 1))  // radians, then convert to degrees
Inclination Interpretation
Equatorial prograde orbit
0°–90° Prograde orbit (tilted from equator)
90° Polar orbit
90°–180° Retrograde orbit (tilted from equator)
180° Equatorial retrograde orbit

Eccentricity display:

Eccentricity describes the orbit shape:

Value Meaning
e = 0 Circular orbit
0 < e < 1 Elliptical orbit
e = 1 Parabolic escape trajectory
e > 1 Hyperbolic escape trajectory

Displayed to 3 decimal places (e.g., “0.012”).

Right Ascension of Ascending Node (RAAN):

RAAN defines the orientation of the orbital plane. It is the angle from a reference direction in the equatorial plane to the ascending node (where the orbit crosses the equator going north).

// Node vector points toward ascending node
n = north × h  // cross product

// Reference direction in equatorial plane (X-axis projected)
ref = normalize(X - (X · north) × north)

// RAAN from atan2
cos_Ω = ref · 
sin_Ω = (ref × ) · north
Ω = atan2(sin_Ω, cos_Ω)  // Convert to 0-360°
Condition Display
Non-equatorial orbit Angle in degrees (1 decimal)
Equatorial orbit (i < 0.1° or i > 179.9°) “N/A”

Argument of Periapsis:

Argument of periapsis defines the orientation of the orbit within its plane. It is the angle from the ascending node to periapsis, measured in the direction of orbital motion.

// Eccentricity vector points toward periapsis
e_vec = (v × h) / μ - 

// Argument of periapsis from node and eccentricity vectors
cos_ω =  · ê
sin_ω = ( × ê) · ĥ
ω = atan2(sin_ω, cos_ω)  // Convert to 0-360°
Condition Display
Non-circular, non-equatorial Angle in degrees (1 decimal)
Circular orbit (e < 0.001) “N/A”
Equatorial orbit “N/A”

True Anomaly:

True anomaly defines the ship’s current position along its orbit. It is the angle from periapsis to the current position, measured in the direction of orbital motion.

// True anomaly from eccentricity vector and position
cos_ν = ê · 
sin_ν = (ê × ) · ĥ
ν = atan2(sin_ν, cos_ν)  // Convert to 0-360°
True Anomaly Position
At periapsis
90° Quarter orbit past periapsis
180° At apoapsis
270° Quarter orbit before periapsis
Condition Display
Non-circular orbit Angle in degrees (1 decimal)
Circular orbit (e < 0.001) “N/A”

Orbital period calculation:

// Orbital period: T = 2π √(a³/μ)
period = 2 * π * sqrt(a³ / μ)  // seconds

For escape trajectories (e ≥ 1), period is undefined and displays “Escape”.

Period display format:

Range Display
< 120 s seconds (e.g., “95 s”)
< 120 min minutes with 1 decimal (e.g., “92.4 min”)
< 48 h hours with 1 decimal (e.g., “23.9 h”)
≥ 48 h days with 1 decimal (e.g., “365.2 d”)

Angular velocity display format:

Angular velocity is shown per axis in degrees per second:

Angular: P: +1.2  Y: -0.5  R: +0.0 °/s
  • P = pitch rate, Y = yaw rate, R = roll rate
  • Sign indicates direction (+ = positive rotation per right-hand rule)
  • Values converted from rad/s to °/s (multiply by 180/π)

Game time display format:

Game time is shown in the status bar in shortened format (no T separator or Z suffix):

2025-01-15 14:32:07
  • Updates each tick
  • Based on simulation start date plus elapsed ticks

Velocity vector indicator:

Setting Value
Toggle key V
Default state Off
Base length 0.05 units (500 km visual)
Max length 0.3 units (3,000 km visual)
Arrowhead Cone at tip indicating direction

When enabled, displays a 3D arrow from the ship indicating velocity direction relative to the nearest body. The vector originates from the ship and points in the direction of travel. Materials use depthTest: false, depthWrite: false so the vector renders on top of ship geometry and is never occluded.

Visual design:

Component Description
Shaft Line from ship to arrow tip
Arrowhead Cone (radius 0.005, height 0.015 units) at tip
Color Speed-based gradient (see below)

Speed-based color gradient (scaled to reference body):

Speed thresholds scale with the reference body’s surface circular orbital velocity v_circ = sqrt(GM/R). This ensures the color progression is meaningful at all bodies — from tiny moons (Phobos, ~8 m/s) to gas giants (Jupiter, ~42 km/s).

Speed Range (fraction of v_circ) Absolute (Earth) Color RGB Hex
0 - 0.38× 0 - 3,000 m/s Green #00ff00
0.38× - 0.88× 3,000 - 7,000 m/s Yellow #ffff00
0.88× - 1.39× 7,000 - 11,000 m/s Orange #ff8800
> 1.39× > 11,000 m/s Red #ff0000

Color transitions smoothly between thresholds using linear interpolation.

Length scaling (scaled to reference body):

Vector length scales logarithmically with speed. Min/max speed thresholds scale with v_circ:

v_circ = sqrt(G * M / R)    # reference body surface orbital velocity
base_length = 0.05          # 500 km visual
max_length = 0.3            # 3,000 km visual
min_speed = v_circ * 0.013  # ~1% of v_circ (below this, vector not shown)
max_speed = v_circ * 6.3    # ~630% of v_circ (above this, length capped)

if speed < min_speed:
    length = 0 (vector hidden)
else:
    t = log10(speed / min_speed) / log10(max_speed / min_speed)
    length = base_length + t * (max_length - base_length)
    length = clamp(length, base_length, max_length)

Fallback: if no reference body, use Earth-scale defaults (min=100, max=50000).

  • All units metric
  • Automatically scale display based on magnitude:
Range Display Unit Format Example
< 10,000 m meters integer 1,234 m
10 km – 1,495,979 km kilometers 1 decimal 1,234.5 km
≥ 1,495,979 km (0.01 AU) AU 3 decimals 1.234 AU

Note: 1 AU = 149,597,870.7 km

Angular velocity vector indicator:

Setting Value
Toggle key O (for Omega)
Default state Off
Base length 0.03 units
Max length 0.15 units
Arrowhead Cone at tip

When enabled, displays a 3D arrow from the ship indicating the axis of rotation (following the right-hand rule: thumb points along vector, fingers curl in rotation direction). Materials use depthTest: false, depthWrite: false so the vector renders on top of ship geometry and is never occluded.

Angular speed-based color gradient:

Angular Speed Color RGB Hex
0 - 1 °/s Cyan #00ffff
1 - 5 °/s Magenta #ff00ff
5 - 15 °/s Yellow #ffff00
> 15 °/s Red #ff0000

Length scaling:

Vector length scales logarithmically with angular speed:

base_length = 0.03
max_length = 0.15
min_speed = 0.1    # deg/s (below this, vector hidden)
max_speed = 30     # deg/s (above this, length capped)

if angular_speed < min_speed:
    length = 0 (vector hidden)
else:
    t = log10(angular_speed / min_speed) / log10(max_speed / min_speed)
    length = base_length + t * (max_length - base_length)
    length = clamp(length, base_length, max_length)

Per-frame vector smoothing:

Both velocity and angular velocity vectors use tick-aligned LERP to interpolate smoothly between 1 Hz server updates, matching the attitude SLERP pattern used for ship mesh rotation:

// On server update: store previous values
prevVelocity = old_velocity
prevAngularVelocity = old_angular_velocity
prevRefBodyVelocity = old_refBodyVelocity
velocity = new_velocity
angularVelocity = new_angular_velocity
refBodyVelocity = referenceBody.velocity  // snapshot at tick time
updateTime = now

// On each render frame (60 FPS):
timeSinceUpdate = now - updateTime
t = min(timeSinceUpdate, 1.0)   // clamp to tick interval
interpolated = prevValue + (currentValue - prevValue) * t

Reference body velocity interpolation: The relative velocity (ship minus reference body) must interpolate both the ship velocity and the reference body velocity. If only the ship velocity is interpolated while the reference body velocity snaps to its new value each tick, the subtraction produces a discontinuity at tick boundaries, causing a visible jerk in the velocity vector. Both velocities are stored as previous/current pairs and LERP’d with the same t factor.

This applies to direction, length, and color — all computed from the interpolated velocity each frame. Both vectors use persistent geometry (created once, updated in-place) to avoid per-tick allocation churn.

Controls

Control Input Description
Pitch W/S keys Rotate nose up/down
Yaw A/D keys Rotate nose left/right
Roll Q/E keys Roll clockwise/counter-clockwise
Thrust up + key Increase thrust by 10%
Thrust down - key Decrease thrust by 10%
Hold attitude H key Toggle attitude hold mode
Prograde P key Orient nose toward velocity
Retrograde Shift+P key Orient nose opposite velocity
Normal J key Orient nose along orbit normal (r × v)
Antinormal Shift+J key Orient nose opposite orbit normal
Radial K key Orient nose perpendicular to velocity in orbital plane
Antiradial Shift+K key Orient nose opposite radial direction
Local horizontal L key Orient nose along horizontal velocity component
Local vertical Shift+L key Orient nose along radial direction from body center
Toggle orbit diagram I key Show/hide SVG orbit diagram window
Toggle orbit diagram scale Z key Switch orbit diagram between ship-only and fit-both-orbits scale
Toggle ship systems U key Show/hide SVG ship systems dashboard
Toggle display options D key Show/hide display options window
Toggle tracers T key Show/hide orbital trajectory lines
Toggle velocity vector V key Show/hide velocity direction arrow
Toggle angular velocity vector O key Show/hide rotation axis arrow
Toggle wireframe Show/hide body wireframe overlays (via Display menu/checkbox only)
Toggle orbital path N key Show/hide predicted orbital path and event markers
Toggle body markers B key Show/hide body name and distance labels
Cycle target forward X key Select nearest target or cycle to next
Cycle target reverse Shift+X key Cycle to previous target
Deselect target Escape key Clear target selection
Target attitude G key Orient nose toward selected target
Anti-target attitude Shift+G key Orient nose away from selected target
Spawn selector R key Open spawn location selector window
Toggle keyboard controls ? key Show/hide keyboard controls window
Quick reset Shift+R key Immediately reset ship to Earth LEO
Refuel F key Refill propellant to capacity
View rotation Mouse / scroll Orbit camera around ship, zoom in/out

Attitude control modes:

The ship supports multiple attitude control modes, accessible via keyboard:

Mode Key HUD Display Behavior
Hold H “HOLD” Rate damping only (stops rotation)
Prograde P “PROGRADE” Orient nose toward velocity
Retrograde Shift+P “RETROGRADE” Orient nose opposite velocity
Normal J “NORMAL” Orient nose along orbit normal
Antinormal Shift+J “ANTINORMAL” Orient nose opposite orbit normal
Radial K “RADIAL” Orient nose perpendicular to velocity in orbital plane
Antiradial Shift+K “ANTIRADIAL” Orient nose opposite radial direction
Local Horizontal L “LOCAL HORIZ” Orient nose along horizontal velocity component
Local Vertical Shift+L “LOCAL VERT” Orient nose along radial direction from body
Target G “TARGET: name” Orient nose toward selected target (requires target)
Anti-Target Shift+G “ANTI-TGT: name” Orient nose away from selected target (requires target)
Target Prograde Y “TGT PRO: name” Orient nose along relative velocity to target (requires target)
Target Retrograde Shift+Y “TGT RET: name” Orient nose opposite relative velocity to target (requires target)

Mode transitions:

From Action Result
None H Enable hold mode
Any mode H Disable attitude control (return to none)
None or Hold P Enable prograde mode
None or Hold Shift+P Enable retrograde mode
Prograde P No change (already prograde)
Retrograde Shift+P No change (already retrograde)
Prograde Shift+P Switch to retrograde
Retrograde P Switch to prograde
None or Hold J Enable normal mode
None or Hold Shift+J Enable antinormal mode
None or Hold K Enable radial mode
None or Hold Shift+K Enable antiradial mode
None or Hold L Enable local horizontal mode
None or Hold Shift+L Enable local vertical mode
Any tracking J Switch to normal
Any tracking Shift+J Switch to antinormal
Any tracking K Switch to radial
Any tracking Shift+K Switch to antiradial
Any tracking L Switch to local horizontal
Any tracking Shift+L Switch to local vertical
Any (with target) G Switch to target tracking
Any (no target) G No change (requires target)
Any (with target) Shift+G Switch to anti-target tracking
Any (no target) Shift+G No change (requires target)
Any (with target) Y Switch to target prograde tracking
Any (no target) Y No change (requires target)
Any (with target) Shift+Y Switch to target retrograde tracking
Any (no target) Shift+Y No change (requires target)

Prograde/Retrograde behavior:

  • Calculates velocity relative to reference body (largest gravitational influence)
  • If velocity < 1 m/s, command is ignored (no meaningful direction)
  • Continuously tracks velocity direction (target updated each physics tick)
  • Ship rotates toward target using reaction wheels and RCS
  • HUD shows current mode until disabled with H

Rate damping (Hold mode):

  • Applies counter-torque proportional to angular velocity
  • Ship rotation slows to zero
  • Does not maintain any specific orientation

Manual override:

  • Rotation inputs (W/A/S/D/Q/E) temporarily override automatic control
  • When keys released, automatic control resumes
  • Attitude control uses reaction wheels (and RCS if wheels saturate)
  • Fuel is consumed only if RCS is needed

Menu bar:

A traditional pull-down menu bar is always visible at the top of the screen after login.

Menu Items
Game Logout
View Orbit Diagram (I), Ship Systems (U), Settings (Shift+D), Tracers (T), Velocity Vector (V), Angular Velocity (O), Wireframe, Orbital Path (N), Body Markers (B), Crosshair (C)
Ship Attitude Hold (H), Prograde (P), Retrograde (Shift+P), Normal (J), Antinormal (Shift+J), Radial (K), Antiradial (Shift+K), Local Horiz (L), Local Vert (Shift+L), Target (G), Anti-Target (Shift+G), Spawn Location (R), Refuel (F)
Help Keyboard Controls (?)

Menu behavior:

  • Click a menu name to open its dropdown
  • Hover between open menus switches the active dropdown
  • Click outside or press ESC to close menus
  • Toggle items show a checkmark (✓) when enabled
  • Keyboard shortcuts still work while menus are closed

UI color contrast (WCAG AA):

All text must meet WCAG AA minimum contrast ratio of 4.5:1 against its background. Secondary/muted text uses #999 (≥4.5:1 on dark backgrounds) instead of #666 (~3:1). Status separators use #888 instead of #444. Hover highlights use sufficient opacity (rgba(102,204,255,0.25) for menus, rgba(102,204,255,0.2) for tree rows) so highlighted text remains legible.

Ship Body Frame

The ship uses a right-handed coordinate system:

Axis Direction Positive Rotation
X Right (starboard) Pitch up (nose rises)
Y Up (dorsal) Yaw left (nose goes left)
Z Forward (nose) Roll left (port wing drops)

Rotation input mapping:

The rotation vector [pitch, yaw, roll] maps to torque around body axes:

Index Name Key (+1) Key (-1) Effect of +1
0 Pitch W S Nose up
1 Yaw A D Nose left
2 Roll Q E Roll left (port wing drops)

This follows aviation convention: pull back (W) to pitch up, push left (A) to yaw left.

  • Rotation keys apply continuous torque while held
  • Releasing stops torque application (ship retains angular momentum)
  • Player must apply counter-rotation to stop spinning
  • Thrust level persists when key released (set and forget)

Client Input Behavior

The client must send WebSocket messages when input state changes:

Rotation (keyboard):

Event Message Sent
W pressed {"type": "rotate", "rotation": [1, 0, 0]}
W released {"type": "rotate", "rotation": [0, 0, 0]}
W + A pressed {"type": "rotate", "rotation": [1, 1, 0]}
A released (W still held) {"type": "rotate", "rotation": [1, 0, 0]}
  • Client tracks key state internally
  • Only sends message when rotation vector changes
  • Multiple keys combine into single rotation vector

Thrust (keyboard):

Event Message Sent
+ pressed {"type": "thrust", "level": <current + 0.1>}
- pressed {"type": "thrust", "level": <current - 0.1>}
  • Thrust increments/decrements by 0.1 per keydown event
  • Holding key: OS key-repeat generates additional keydown events (typically 30 Hz after initial delay)
  • Clamped to 0.0 - 1.0 range (no message sent if already at limit)
  • Client tracks current thrust level
  • Unlike rotation, thrust changes are discrete steps, not continuous

Connection

  • WebSocket: Persistent connection for real-time telemetry and control
  • Commands sent immediately on player input

Update Frequency

  • Server pushes state updates every tick (default: 1 Hz)
  • Maximum update rate capped at 10 Hz (even if tick rate is higher)
  • Updates include: player’s ship state, all other ships, all celestial bodies, game time
  • All bodies and ships sent each update (negligible bandwidth for MVP scale)
  • Client handles rendering/indicator decisions locally

WebSocket Messages

Server → Client

Message Payload Description
welcome player_id, ship_id, config, system_id Sent on successful connection. When system_id differs from state.currentSystemId, client clears stale entity state (bodyData, stationStates, jumpgateStates) before updating — prevents phantom bodies from a previous system. Uses in-place delete (not reassignment) since other modules hold references to these objects.
state ship, bodies[], ships[], tick Game state update
player_joined player_id, ship_id, name Another player connected
player_left player_id, ship_id Another player disconnected
game_paused paused_at_tick Admin paused the game
game_resumed resumed_at_tick Admin resumed the game
game_restored restored_to_tick, game_time Admin restored from snapshot
tick_rate_changed previous_rate, new_rate Admin changed tick rate
token_refresh token New JWT token (sent 1 hour before expiry)
pong timestamp Response to ping
error code, message Error notification (invalid input, etc.)

Connection Sequence

  1. Client opens WebSocket: wss://galaxy.example.com/ws (no token in URL)
  2. Client starts a 10-second connection timeout
  3. On open, client sends auth message: {"type": "auth", "token": "<jwt>"}
  4. Server validates token; on failure closes with code 4001
  5. Server sends welcome with player info — connection timeout is cleared
  6. Server immediately sends first state message with current game state
  7. Server begins sending state updates each tick
  8. Client sends ping every 30 seconds; server responds with pong
  9. If no pong received within 10 seconds, client should reconnect

Tokens are never sent in URL query strings to avoid exposure in HTTP access logs, browser history, and proxy logs. The server closes the connection (code 4001) if no valid auth message arrives within 5 seconds of the WebSocket upgrade.

If the connection timeout fires (WebSocket does not reach OPEN state within 10 seconds), the client closes the socket and triggers the reconnection backoff sequence.

WebSocket Reconnection

When the WebSocket connection closes (server restart, network interruption, ping timeout), the client reconnects automatically using exponential backoff with jitter:

Parameter Value
Initial delay 1 second
Multiplier 2x per attempt
Maximum delay 30 seconds
Jitter +/- 20% of computed delay
Reset On successful connection (onopen)

Backoff sequence (before jitter): 1s, 2s, 4s, 8s, 16s, 30s, 30s, …

Jitter prevents thundering herd when the server restarts and multiple clients reconnect simultaneously. Each delay is multiplied by a random factor in [0.8, 1.2].

Connection status displayed in the status bar:

  • connected (green dot) — WebSocket open
  • reconnecting (yellow dot) — connection lost, backoff in progress
  • disconnected (red dot) — not yet connected

The backoff counter resets to 0 when onopen fires, so subsequent disconnections start fresh at 1 second.

WebSocket Authentication Failures

Token validation happens after the WebSocket upgrade via a message-based handshake. The server accepts the connection, then waits up to 5 seconds for an auth message. Failures result in a WebSocket close frame:

Condition Close Code Close Reason
Any authentication failure 4001 Authentication failed
Token valid, server full 4013 Server full
Token valid welcome sent

All auth failures use the same generic close reason ("Authentication failed") to prevent information leakage about authentication state. The specific failure reason is logged server-side for debugging.

On receiving close code 4001, the client should display an authentication error and redirect to the login screen instead of reconnecting.

Error Codes

Code Message Description
E001 Invalid rotation value Rotation axis value outside -1 to 1
E002 Invalid thrust value Thrust value outside 0 to 1
E003 Invalid service type Unknown service requested
E004 Rate limit exceeded Too many requests
E005 Authentication failed Invalid or expired token
E006 Ship not found Player’s ship does not exist
E007 Service unavailable Requested service temporarily unavailable
E008 Server error Internal server error
E009 Player not found Player account does not exist
E010 Username taken Username already registered
E011 Invalid username Username format invalid (must be 3-20 chars, alphanumeric + underscore)
E012 Invalid password Password too short (minimum 8 characters)
E013 Server full Maximum concurrent connections reached (16)
E014 Account deleted Account deleted by administrator
E015 Invalid tick rate Tick rate outside valid range (0.1-100.0 Hz)
E016 Snapshot not found Requested snapshot does not exist
E017 Not initialized Physics service not yet initialized (startup race)
E022 Target not found Target entity does not exist
E023 Relative velocity too low Relative velocity too low for target prograde/retrograde
E024 Too close to target Too close to target for target/anti-target mode
E025 Rule limit reached Maximum 10 automation rules per ship
E026 Invalid automation rule Automation rule validation failed
E027 Rule not found Automation rule does not exist or belongs to different ship

Invalid Message Handling

Server behavior for malformed or invalid WebSocket messages:

Condition Response Disconnect?
Malformed JSON Send error E008 No
Unknown message type Ignore silently No
Valid type, invalid payload Send error (E001-E003) No
Message too large (> 64 KB) Close connection Yes
Too many errors (> 10 in 60s) Send E004, close connection Yes

Rationale:

  • Ignoring unknown types allows forward compatibility (new message types don’t break old clients)
  • Size limit prevents memory exhaustion attacks
  • Error rate limit prevents abuse while allowing occasional mistakes

Input Validation

Both client and server validate all inputs:

Input Valid Range On Invalid
rotation -1.0 to 1.0 per axis Reject with error
thrust 0.0 to 1.0 Reject with error
service “fuel”, “reset” Reject with error
  • Client validates before sending (immediate feedback)
  • Server validates on receipt (security, authoritative)
  • Invalid messages rejected with error response, not silently clamped

Rate Limiting

Command Type Limit Rationale
rotate, thrust 60/sec Continuous control inputs
service (fuel, reset) 1/min Prevent abuse
login attempts 5/min Prevent brute force

Exceeding rate limit returns error; repeated violations may disconnect client.

Client → Server

Message Payload Description
ping timestamp Heartbeat check
rotate vec3 (-1 to 1) Pitch/yaw/roll input
thrust float (0-1) Set thrust level
attitude_hold bool Enable/disable attitude hold
service type Request service (fuel, reset)

Control Command Flow

When client sends a control command (rotate or thrust):

Step Action
1 Client sends WebSocket message to api-gateway
2 api-gateway validates message format
3 api-gateway calls physics.ApplyControl via gRPC (synchronous)
4 physics updates ship’s control state in Redis immediately
5 physics returns success/error to api-gateway
6 If error: api-gateway sends error message to client
7 If success: no response sent (fire-and-forget)
8 Next tick: physics processes ship with updated control values

Timing:

Phase Typical Latency
WebSocket → Redis update < 10 ms
Control visible in state Next tick (up to 1 second at 1 Hz)

Notes:

  • Successful control commands receive no acknowledgment
  • Client can send controls at up to 60 Hz; only latest values used per tick
  • Controls persist until changed (thrust stays at set level, rotation resets to 0 when key released)

Disconnection Behavior

  • Game world continues regardless of player connection status
  • Ship maintains current trajectory and thrust settings
  • On reconnect, client receives current state and resumes control

Catch-Up Behavior

When server is catching up on missed ticks:

  • Player receives welcome message immediately on connect
  • Ticks processed at maximum speed (no wall-clock delay between ticks)
  • WebSocket state updates sent at most every 100ms wall-clock (10 Hz max)
  • Each update contains the latest processed state (intermediate ticks not sent individually)
  • Player sees game time advancing rapidly in HUD
  • Player controls remain responsive (inputs queued and applied to next tick)
  • Once caught up (real-time = game time), updates resume at normal tick rate (1 Hz)

Example: If server is 1000 ticks behind and processes at 500 ticks/second:

  • Catch-up takes ~2 seconds wall-clock
  • Client receives ~20 state updates during this period
  • Each update shows progressively later game_time

Authentication Screens

Registration

Registration gate:

On login page load, the client fetches GET /api/status and checks registration_open. If false, the Register button is hidden. If the fetch fails or registration_open is true (or absent), the Register button is shown (fail-open).

Field Validation
Username 3-20 characters, alphanumeric + underscore
Password Minimum 8 characters
Confirm password Must match

Server full behavior:

Registration always succeeds if credentials are valid (account created in PostgreSQL, ship spawned in Redis). The 16-connection limit only applies to WebSocket connections, not account creation.

Server State Registration Post-Registration
< 16 online Success, account created Auto-connect to game
16 online Success, account created Show “Server full, try again later”

When server is full after registration, client displays message and offers retry button. Player can connect once a slot becomes available.

Login

Field Validation
Username Required
Password Required
  • Failed login shows generic error (no username enumeration)
  • Successful login redirects to cockpit view

Loading state:

Both login and register buttons show a loading state while the request is in flight:

  • Buttons are disabled (disabled attribute)
  • Button text changes to “Logging in…” or “Registering…”
  • On failure, buttons are re-enabled with original text and error message is shown
  • On success, the login modal is hidden (buttons remain in loading state — not visible)

Login page footer:

Below the login form, the login modal displays:

  • Beta status badge: “BETA” in uppercase, orange (#ff8844)
  • Documentation link: “Documentation” linking to GitHub Pages (https://erikevenson.github.io/galaxy/)
  • Version info: “Web Client: x.y.z” — uses __APP_VERSION__ (available at build time, no server connection needed)
Setting Value
Font Monospace, 11px
Color #666 (muted)
Alignment Center
Link color #6cf
Position Below the error div, separated by margin

Map View

3D system map for situational awareness and navigation planning.

Activation

Setting Value
Menu location View > Map View
Default state Off (cockpit view active)

Selecting “Map View” from the menu switches from cockpit view to map view. Selecting “Cockpit View” (same menu location) switches back. Only one view is active at a time.

When map view is active:

  • The cockpit Three.js scene stops rendering (performance)
  • Ship controls are disabled (no thrust, rotation, or attitude commands)
  • The HUD is hidden
  • The game simulation continues server-side (ships keep moving)
  • WebSocket state updates continue and are reflected in the map

When switching back to cockpit view:

  • The cockpit scene resumes rendering with current state
  • Ship controls are re-enabled
  • The HUD is restored

Scene

The map view uses a separate Three.js scene and renderer from the cockpit view.

Parameter Value Description
Scale factor 1e-10 1 unit = 10 billion km
Sun radius 0.07 units 696,000 km
Earth orbit ~15 units ~1 AU
Neptune orbit ~450 units ~30 AU
Pluto orbit ~590 units ~39 AU

System-relative coordinate origin:

The map view uses system-relative rendering — all positions are rendered relative to the current system’s primary star, placing the star at the Three.js origin. This is necessary because different star systems have absolute ICRF positions light-years apart (e.g., Barnard’s Star is ~6 light-years from Sol, or ~5×10¹⁶ meters), which would place them millions of map units from the camera.

Each frame, the system origin offset is computed from the primary star’s Verlet-propagated position (_clientPos). This offset (in ICRF/physics coordinates) is subtracted from all absolute positions before scaling to map coordinates:

map_x = (icrf_x - star_x) × SCALE
map_y = (icrf_z - star_z) × SCALE
map_z = -(icrf_y - star_y) × SCALE

The offset is stored on the view as _systemOrigin and used by:

  • Body mesh and indicator positioning (extrapolatePositions)
  • Ship, station, jump gate indicator positioning
  • Lagrange point positioning
  • Camera fly-to calculations (_selectShip, _selectStation, _selectJumpgate)
  • View lock target tracking (_updateViewLock)
  • Remote system preview positioning (enterRemoteMode)

Orbital path lines are unaffected — they use parent-relative vertex coordinates positioned at the parent body mesh, which is already offset correctly.

Renderer:

Setting Value
Depth buffer Logarithmic
Near plane 0.001 units
Far plane 10,000 units
Background Black (0x000000)
Antialias On

Lighting:

Light Type Properties
Sun PointLight Position: system star mesh, intensity: 3, decay: 0
Ambient AmbientLight Low intensity for visibility of distant bodies

Camera

Free-flying perspective camera with no fixed target.

Setting Value
Type PerspectiveCamera
FOV 50°
Initial position Above ecliptic, viewing inner solar system
Movement WASD + mouse (FlyControls or similar)
Speed Adaptive based on distance from nearest body

Controls:

Control Input Description
Pan Right-click drag Pan camera laterally
Rotate Left-click drag Rotate camera orientation
Zoom Scroll wheel Move camera forward/backward
Go to body Left-click body indicator Smooth fly to clicked body
Go to ship Left-click ship indicator Smooth fly to clicked ship

Smooth camera transitions:

When clicking a body or ship indicator, the camera flies smoothly to frame the target:

transition_duration = 1.5 seconds
easing = ease-in-out (cubic)
target_distance = max(body_radius × 5 × SCALE, 0.5 units)

The camera interpolates both position and look-at target over the transition duration.

Fly-to cancellation: If the player interacts with camera controls (mouse down, scroll, touch start) during a fly-to animation, the animation is cancelled immediately. The camera remains at its current interpolated position and the player regains manual control. This is implemented by listening for the OrbitControls start event and setting the fly-to state to null.

Body Rendering

Bodies are rendered at true geometric scale using the map SCALE factor. At system-wide zoom levels, most bodies are sub-pixel. Constant-sized indicators ensure all bodies remain visible.

Geometry:

Bodies are rendered as spheres at true scale (radius × SCALE). The Sun is the only body with visible geometry at system-wide zoom.

Indicators:

Every body has a constant-sized indicator visible regardless of zoom:

Component Description
Dot Filled circle, 6px screen-space
Label Body name, positioned above dot
Distance Distance from selected body, below name

Indicator dot colors match body type:

Type Color RGB Hex
Star Yellow #ffff00
Planet (rocky) Tan #cc9966
Planet (gas giant) Orange #ff9944
Planet (ice giant) Cyan #66ccff
Moon Gray #aaaaaa

Indicators use CSS2DRenderer (HTML overlay) so they remain crisp at any zoom level and are easily clickable.

Distance display:

When a body is selected, all other indicators show the distance from the selected body:

distance = |target.position - selected.position|
Range Unit Format
< 10,000 km km integer
10,000 km – 0.01 AU Mm 1 decimal
≥ 0.01 AU AU 3 decimals

Body Orbital Paths

Each planet and moon displays its orbit as a path line, computed using 2-body Keplerian analysis relative to its parent body (planets relative to Sun, moons relative to their planet).

Computation:

// For each body, compute orbit relative to parent:
μ = G × parent.mass
r = body.position - parent.position
v = body.velocity - parent.velocity

// Orbital elements from state vectors (same as cockpit orbital elements)
// Generate ellipse/hyperbola points from elements

The path computation reuses the same orbital mechanics as the cockpit view’s predicted orbital path (Keplerian 2-body solution, not N-body propagation).

Visual properties:

Property Value
Line style Solid
Opacity 0.3
Color Same as body indicator dot color
Update frequency On server state update (1 Hz)
Points per orbit 360 (elliptical)

Path positioning:

Orbital path geometry is computed in parent-body-relative coordinates and positioned at the parent body mesh, matching the cockpit view approach. This avoids float32 precision issues in the vertex buffer.

Hyperbolic orbits:

For escape trajectories (e ≥ 1), render the path arc from current true anomaly ±90° or to SOI boundary, whichever is smaller.

Ship Rendering

Ships are shown as constant-sized indicators with predicted orbital paths.

Indicators:

Component Description
Dot Filled triangle, 8px screen-space
Label Ship name, positioned above dot
Distance Distance from selected body, below name

Ship indicator colors:

Ship Color RGB Hex
Own ship Green #00ff00
Other players Orange #ff8800

Predicted orbital paths:

Each ship displays a predicted orbital path using the same 2-body Keplerian analysis as the cockpit view, relative to the ship’s current reference body (strongest gravitational influence).

Property Value
Line style Solid
Opacity 0.5
Own ship path color Green (#00ff00)
Other ship path color Orange (#ff8800)
Update frequency On server state update (1 Hz)
Points per orbit 360 (elliptical)

Path geometry is parent-body-relative, positioned at the reference body.

Station Rendering

Stations are displayed as constant-sized indicators with predicted orbital paths, following the same pattern as ships.

Indicators:

Property Value
Dot color Cyan (#00cccc)
Label Station name, positioned above dot
Distance Distance from selected body, below name
Type CSS2DObject (same system as body/ship indicators)

Predicted orbital paths:

Each station displays a predicted orbital path using the same 2-body Keplerian analysis as ships, relative to the station’s parent body.

Property Value
Line style Solid
Opacity 0.5
Path color Cyan (#00cccc)
Update frequency On server state update (1 Hz)
Points per orbit 360 (elliptical)

System browser:

Stations appear in the system browser tree under their parent body, listed after moons. Clicking a station selects it and flies the camera to it.

Selection

Clicking a body or ship indicator selects it and flies the camera to it.

Behavior Description
Click body Select body, fly camera to it
Click ship Select ship, fly camera to it
Click empty space Deselect (clear selection)

Selected state:

Visual Description
Indicator ring Pulsing highlight ring around selected indicator
Distance labels All other indicators show distance from selected object

When nothing is selected, distance labels are hidden.

Map view selection is synchronized with the shared target state (state.targetId, state.targetType). Selecting a body, ship, or station in map view also sets the navigation target for the cockpit view.

Information Panel

A panel displays details about the selected object.

Selected body panel:

Field Description
Name Body name
Type Star / Planet / Moon
Mass In kg (scientific notation)
Radius In km
Orbital period Formatted duration
Parent Parent body name

Selected ship panel:

Field Description
Name Ship name
Owner Player name
Speed Relative to reference body
Fuel Percentage remaining
Reference body Nearest gravitational influence
Orbital elements Pe, Ap, eccentricity, inclination

Panel placement:

Setting Value
Position Right side of screen
Width 280px
Style Semi-transparent dark background
Visibility Shown only when an object is selected

State Sharing

The map view and cockpit view share the same game state from the WebSocket connection:

  • Body positions and velocities
  • Ship positions, velocities, and metadata
  • Game time and tick number

No additional server messages are needed. The map view reads from the same state store that the cockpit view uses. This requires extracting shared state management from the cockpit rendering code.

Architecture:

WebSocket → State Store (shared)
               ├── Cockpit View (renders when active)
               └── Map View (renders when active)

Shared Modules

Texture Loading (textures.js)

Body textures are lazy-loaded on demand via a singleton TextureManager class shared between both views.

TextureManager class:

  • constructor(loader) — takes a THREE.TextureLoader instance; tracks per-texture state (idle / loading / loaded / failed)
  • preload(names) — fire-and-forget load for an array of texture names (used at init for spawn-area textures)
  • get(name) — synchronous lookup; returns loaded texture or null (does not trigger loading)
  • request(name, callback) — triggers load if state is idle; calls callback(texture) when ready (immediately if already loaded)
  • isLoaded(name) — returns true if texture is in loaded state
  • _startLoad(name) — internal: calls TextureLoader.load(), sets SRGBColorSpace on the loaded texture, transitions state to loaded, resolves all pending callbacks; on error transitions state to failed (failed textures are not retried)

Preload set: ['sun', 'earth', 'moon'] — loaded at init since players spawn near Earth.

Distance-based loading: Cockpit view calls _checkTextureLoading() every ~60 frames (~1 Hz) inside extrapolatePositions(). For each body in state.bodyData, if the ship-to-body distance is less than 200 × body.radius, the body’s texture is requested via TextureManager.request(). Saturn additionally requests 'saturn-ring'.

Material swap: When a texture loads, a callback replaces the body mesh’s fallback solid-color material with the textured material (disposing the old material). The swap checks body.material.map to avoid redundant swaps. Saturn ring textures are applied to the ring child mesh identified by userData.isRing.

Helper: bodyTexKey(name) — maps body name to texture key ('Luna''moon', otherwise name.toLowerCase()).

Texture paths: All body textures are JPEG files at /textures/<name>.jpg with SRGBColorSpace. Saturn’s ring texture is a PNG at /textures/saturn-ring.png (for alpha transparency).

Draggable Windows (draggable.js)

Floating window drag behavior is provided by a shared makeDraggable(windowEl, headerEl, closeBtn, options) function. It attaches a mousedown listener to the header that initiates drag tracking:

  • Clicks on the close button are ignored (not captured by drag)
  • Window position is clamped to the viewport (left ≥ 0, top ≥ 28 for menu bar, right/bottom within screen)
  • Document-level mousemove and mouseup listeners are added only during an active drag and removed on mouse-up
  • Bring-to-front on click: A mousedown listener on the entire windowEl (not just the header) calls bringToFront(windowEl), which increments a module-level z-index counter and applies it. This raises the clicked window above all other floating windows. The z-index is capped at 549 (below the crosshair at 550); when the cap is reached, all .floating-window elements are reset to the base z-index (500) and the target window is set to 501

The options.resetProperties array specifies CSS properties to reset on first drag, handling windows that use non-left/top initial positioning:

Window Initial CSS Position resetProperties
Spawn Selector transform: translate(-50%,-50%) ['transform']
About transform: translate(-50%,-50%) ['transform']
Settings right: 250px ['right']
System Browser left: 20px ['right']
Controls bottom: 52px ['bottom']

Properties in resetProperties are set to 'none' for transform and 'auto' for all others.

Shared Window Visibility Across Views

All floating windows are available in both cockpit and map views. Windows preserve their open/closed state and position when switching views via the M key.

Implementation: CockpitView’s _handleMenuAction() dispatches document events for all window toggles (including settings, controls, about, spawn selector). Both views listen for these events in their activate()/deactivate() lifecycle. The switchView() function in main.js does not hide or show floating windows — each view’s deactivate() only hides view-specific elements (canvas, CSS overlay, crosshair) and leaves floating windows untouched.

Window visibility state is stored per-window (not per-view) in settings.windowPositions and settings.*Visible keys, so toggling a window in one view keeps it visible when switching to the other.

Event Listener Lifecycle

Resize Handlers

Each view’s _resizeHandler is registered in activate() and removed in deactivate(). This prevents inactive views from responding to window resize events.

Viewport clamping on resize: When the browser window shrinks, floating windows may end up off-screen. Both views’ resize handlers call clampFloatingWindows() (from draggable.js) which iterates all visible .floating-window:not(.hidden) elements and clamps them into the viewport using the same bounds as drag (left ≥ 0, top ≥ 28, right/bottom within screen). Windows using CSS right or bottom positioning are converted to left/top before clamping, and conflicting properties (right, bottom, transform) are cleared.

Floating Window Drag Handlers

Floating windows (Spawn Selector, Orbit Diagram, Settings, About, System Browser) use mousedown-scoped drag listeners via the shared makeDraggable() function. Document-level mousemove and mouseup listeners are added only during an active drag (on mousedown) and removed when the drag ends (on mouseup). This avoids persistent document listeners that fire on every mouse movement.

Document-Level Event Listeners

Both views register document.addEventListener listeners for menu close events, toggle events, and keyboard shortcuts. These must follow the same activate/deactivate lifecycle as resize handlers to prevent execution when the view is inactive.

CockpitView document listeners (stored as instance properties, added in activate(), removed in deactivate()):

Property Event Target Purpose
_onMenuClose click document Close menus when clicking outside menu bar
_onMenuEscape keydown document Close menus on Escape key
_onToggleOrbitDiagram toggle-orbit-diagram document Toggle orbit diagram from menu
_onToggleShipSystems toggle-ship-systems document Toggle ship systems from menu
_onToggleTargetDashboard toggle-target-dashboard document Toggle target dashboard from menu

MapView document listeners (stored as instance properties, added in activate(), removed in deactivate()):

Property Event Target Purpose
_onToggleSystemBrowser toggle-system-browser document Toggle system browser from menu
_onToggleOrbitDiagram toggle-orbit-diagram document Toggle orbit diagram from menu
_onToggleShipSystems toggle-ship-systems document Toggle ship systems from menu
_onShipSystemsKeydown keydown document Ship systems keyboard shortcut (U)
_onToggleTargetDashboard toggle-target-dashboard document Toggle target dashboard from menu
_onTargetDashboardKeydown keydown document Target dashboard keyboard shortcut (Shift+T)

Bound handler references are created in init() (e.g., this._onMenuClose = (e) => { ... }), registered in activate() via document.addEventListener(...), and removed in deactivate() via document.removeEventListener(...).

Three.js Resource Disposal

Three.js GPU resources (BufferGeometry, Material, Texture) must be explicitly disposed to prevent WebGL memory leaks. Disposal rules:

On deactivate (cockpit view):

  • Starfield: this.starfield.geometry.dispose() and this.starfield.material.dispose()

On removal (both views):

  • Body meshes: Bodies are permanent (never removed), so no disposal path needed
  • Ship/station meshes: Geometry and material disposed when removed from scene (already implemented for tracers, orbital paths)

Camera Debounce Timeout Cleanup

Both views use a debounced camera-save callback on OrbitControls change events. The debounce timeout is promoted to an instance property (this._cameraDebounce) and cleared in deactivate() via clearTimeout(this._cameraDebounce) to prevent stale closures firing after a view switch.

View Settings Persistence (settings.js)

Client-side view/display preferences are persisted in localStorage under the key galaxy:viewSettings as a flat JSON object. Settings are loaded on init and restored before view construction. Each toggle (keyboard, menu, or checkbox) saves immediately after state change.

Persisted settings:

Key Default Description
activeView 'cockpit' Last active view ('cockpit' or 'map')
tracers false Ship tracers (trails)
velocityVector false Velocity vector arrow
angularVelocityVector false Angular velocity vector arrow
orbitalPath false Predicted orbital path
markerVisibility {bodies: true, ships: true, stations: true, lagrangePoints: true, jumpgates: true} Per-category marker visibility
wireframe false Wireframe overlay
cockpitCrosshair true Cockpit view crosshair
orbitDiagram false Orbit diagram window
shipSystems false Ship systems window
targetDashboard false Target dashboard window (3D target view)
navLights true Own-ship navigation light blink enabled
headlight true Own-ship headlight glow visible
targetId null Selected target ID (body name, ship_id, station_id, or lagrange key)
targetType null Selected target type ('body', 'ship', 'station', 'lagrange')
mapCrosshair true Map view own-ship pulsing ring
mapSystemBrowser false System browser window
cockpitCamera null {offset: {x,y,z}} — camera offset from ship (cockpit camera follows ship)
mapCamera null {position: {x,y,z}, target: {x,y,z}} — map OrbitControls state
windowPositions {} {orbital?: {left, top}, orbitDiagram?: {left, top}, shipSystems?: {left, top}, targetDashboard?: {left, top}} — dragged window positions

Not persisted: Attitude/thrust (server state), about/controls/display windows (transient), spawn selector (transient). Target selection is persisted but gracefully cleared on load if the target no longer exists (e.g., a ship that logged off).

Camera persistence: Camera state is saved on view switch (deactivate()), on page unload (beforeunload), and debounced (1 s) on OrbitControls change events. The debounced save ensures camera state survives tab kills and mobile browser eviction where beforeunload may not fire. Default null means “use the hardcoded initial position”. Cockpit view saves the camera offset from the ship (since the camera follows the ship every frame); the offset is applied relative to the ship position on the first state update. Map view saves absolute position/target; on first activation with saved state, the fly-to-own-ship animation is skipped.

Window position persistence: The five persistable windows (orbital, ship status, orbit diagram, ship systems, target dashboard) save their drag position via an onDragEnd callback in makeDraggable(). On init, saved positions are restored by setting style.left/style.top and clearing any conflicting CSS right/transform. Saved positions are clamped to the current viewport on restore (left ≥ 0, top ≥ 28, right/bottom within screen) since the viewport may have shrunk since the position was saved. Transient windows (about, controls, display, spawn selector, system browser) are not persisted.

Viewport clamping on toggle-visible: When a hidden floating window is toggled visible, its position is clamped to the current viewport via clampWindow() (from draggable.js). This handles the case where the browser was resized while the window was hidden — clampFloatingWindows() only operates on visible windows, so hidden windows miss the resize clamp. The clampWindow(win) function applies the same bounds as drag and resize clamping (left ≥ 0, top ≥ 28, right/bottom within screen).

Migration: When loading settings, if the legacy bodyMarkers boolean key exists, its value is applied to all five markerVisibility categories and the old key is removed. This preserves the user’s previous all-on/all-off state.

Forward-compatibility: loadSettings() merges saved values with current defaults using spread ({ ...DEFAULTS, ...saved }), so new settings added in future versions automatically get their default values.

Error handling: loadSettings() falls back to defaults on JSON parse error or missing key. saveSettings() silently ignores quota exceeded or private browsing errors.

Build Configuration

Code Splitting

Vite manualChunks separates rarely-changing dependencies from frequently-changing app code for better HTTP cache granularity.

Chunk Contents Changes
vendor-three Three.js + OrbitControls + CSS2DRenderer Rare (dependency updates only)
index All app code (main, views, utilities) Frequent (every deploy)

Both chunks use content-hashed filenames ([name]-[hash].js). Nginx serves them with Cache-Control: immutable, so browsers only re-download a chunk when its content actually changes.

Security: DOM Construction

All dynamic content rendered in the browser MUST avoid innerHTML with interpolated strings, even when data currently originates from trusted server sources. Data provenance can change (e.g., player-chosen ship names displayed in tooltips, labels, or chat), so the client must be defensively coded.

Rules:

  • Plain text only — use element.textContent = value.
  • Clearing children — use element.replaceChildren() (no arguments) instead of element.innerHTML = ''.
  • Structured HTML needed — build the DOM tree programmatically with document.createElement() / element.appendChild() and set text via textContent.
  • Never pass dynamic values through HTML string templates (`<tag>${value}</tag>`) into innerHTML.

Affected locations (fixed):

File What changed
shipSystems.jssetupShipSystemsTooltips Tooltip built with createElement/textContent instead of innerHTML template
automationView.js_renderRuleList Empty-state message built with createElement; list clearing uses replaceChildren()
automationView.js_openRuleForm Condition container clearing uses replaceChildren()
mapView.js_rebuildSystemTree Container clearing uses replaceChildren()

Future Releases

  • Numerical readouts and telemetry
  • Transfer orbit planning (map view)
  • Ship selection and management UI
  • Navigation/autopilot interface

Back to top

Galaxy — Kubernetes-based multiplayer space game

This site uses Just the Docs, a documentation theme for Jekyll.