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:
- 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. - Clustering (union-find): Two indicators within
CLUSTER_RADIUS(30px) screen distance are merged into the same cluster. O(n²) with n ≤ ~40 is trivial. - 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
- 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. - 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 MeshBasicMaterialobjects (starfield, nav lights, engine plume) havetoneMapped = 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.Pointson 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.GroupnamednavLights
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
navLightsgroup visibility inextrapolatePositions()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
BufferGeometrywith pre-allocated vertex buffer (MAX_TRACE_POINTS × 3floats) - Vertices updated in-place;
setDrawRange()controls visible portion - Material and
THREE.Linecreated 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) * SCALEinstead oftracePoint * SCALE - The mesh is positioned at
referenceBodyPosition + shipRelativePosition * SCALEinstead ofreferenceBodyPosition - 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
BufferGeometrywith pre-allocated vertex buffer (362 × 3 floats);setDrawRange()for actual point count - Material and
THREE.Linecreated 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
r̂as basis instead ofe_vec(ship true anomaly is naturally 0) - Near-radial (p < rShip × 0.001): draw radial line along
r̂(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.radiusalongr̂(body surface in ship’s radial direction); time-to =(r - body.radius) / |v_radial|whenv_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:
- Get child position relative to reference body (projected into orbital plane coordinates)
- Compute child’s Hill sphere radius via
computeHillSphereRadius - Sample ship orbit at N points (360 elliptical, 180 hyperbolic)
- At each sample, compute distance from orbit point to child center
- Find segments where distance crosses inward through
rSOI(entry point) - Bisect to refine the true anomaly at crossing (~10 iterations for sub-degree precision)
- 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:
- Detects BOTH inward crossings (entry,
prevDist > rSOI && dist <= rSOI) and outward crossings (exit,prevDist <= rSOI && dist > rSOI) - Returns ALL crossings, not just the nearest
- 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)
depthWritedisabled,depthTestdisabled,sizeAttenuationdisabled (constant screen-space size)- Small colored dot (4 px,
sizeAttenuationdisabled) 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:
ν → Eviatan(E/2) = √((1-e)/(1+e))·tan(ν/2), thenM = E - e·sin(E),Δt = ΔM/(2π) × T - Hyperbolic:
ν → Hviatanh(H/2) = √((e-1)/(e+1))·tan(ν/2), thenM_h = e·sinh(H) - H,Δt = ΔM_h / nwheren = √(μ/(-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-indicatorCSS 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
markerVisibilitysettings 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-indicatorCSS classes, andcssRendererfrom body markers - Ship indicators stored in
this.shipIndicatorsmap (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.jswithcomputeLagrangePoints(primary, secondary)andgetRelevantPairs(refBodyName, bodyData) LAGRANGE_POINT_COLORconstant inbodyConfig.js- Indicators stored in
this.lagrangeIndicatorsmap (keyed by “Primary-Secondary-Ln”) - Positions recomputed each frame from extrapolated body positions
- Indicators rebuilt when reference body changes (
this._currentLagrangeRefBodytracking)
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.crosshairEnabledproperty (default:true)MapView.crosshairEnabledproperty (default:true)- Cockpit: show/hide
#cockpit-crosshairelement on activate/deactivate and toggle - Map: show/hide
.map-indicator-ringon 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
polygonOffsetfor 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.
- Formula:
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
Zto 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)whererShipis 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
rShipis passed throughupdateOrbitDiagram()fromstate.referenceBody.distance
Hyperbolic path extent (computeHypNuMax):
- The SVG path for hyperbolic orbits extends from
−nuMaxto+nuMax(symmetric about periapsis) nuMaxis computed bycomputeHypNuMax(e, p, nuShip, maxDrawR)inorbitDiagramCalc.js:- Start at
acos(−1/e) − 0.001(tight asymptote margin) - Cap to where
r(ν) = maxDrawRifmaxDrawR > 0:nuCap = acos((p/maxDrawR − 1)/e), takemin(nuMax, nuCap) - Extend to include ship:
nuMax = max(nuMax, min(|nuShip| + 0.02, asymptote − 0.001))
- Start at
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:
- 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.
- The out-of-plane distance
his 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. - The effective in-plane SOI radius is
sqrt(r_SOI² − h²). - The orbit is sampled at 720 points (elliptical: one full orbit 0→2π; hyperbolic: −nuMax→+nuMax).
- At each sample, the distance from the orbit point
(r·cos ν, r·sin ν)to the child’s perifocal-frame center is computed. - Transitions are detected: outside→inside = entry, inside→outside = exit.
- Each crossing is refined via 12-iteration bisection to sub-degree accuracy.
- 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-tooltipattribute 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:
- Navball (y=0–165): Attitude indicator with horizon, pitch ladder, and orbital markers
- Fuel/Thrust gauges (y=165–270): Vertical bars with numeric readouts; max thrust label above thrust gauge
- Alt/Spd (y=270–295): Altitude and speed readouts
- Rotation rates (y=295–345): Graphical bidirectional bars (P/Y/R, ±10°/s range). Interpolated at 60 FPS via LERP between
prevAngularVelocityandangularVelocity. - Reaction wheels (y=350–400): Graphical unidirectional bars (P/Y/R, 0–100%). Interpolated at 60 FPS via LERP between
prevWheelSaturationandwheelSaturation. - 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:
- Sky/ground halves — sky
#112244, ground#443322, split by horizon line - Horizon line — moves vertically by pitch, tilts by roll (relative to local vertical from reference body)
- Pitch ladder — horizontal lines at ±10°, ±20°, ±30° intervals
- 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 |
- 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 betweenprevAttitudeandattitudeusingtimeSinceUpdate = now - ss.updateTime, clamped to [0, 1] — identical to the 3D mesh interpolation inextrapolatePositions() - The interpolated attitude is passed to
updateNavball(refs, shipData, orbitalData)whereshipData.attitudecontains 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:
CLEARin 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-tooltipattribute - 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:
-
“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(notbody). Shows the focused target’s name when available. -
Segmented control — Four category buttons: Stations, Gates, Ships, Lagrange. Clicking a segment shows the corresponding picker and hides others. Active segment is visually highlighted.
-
Per-category pickers:
- Stations:
<select>listing all stations sorted by name, valuesstation:{id} - Gates:
<select>listing all jumpgates sorted by name, valuesjumpgate:{id} - Ships:
<select>listing other players’ ships sorted by name, valuesship:{id} - Lagrange: Two-level picker — system
<select>listing all 28+ parent–child body pairs fromBODY_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).
- Stations:
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: trueto 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.viewLockTargetKeyis 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.quaterniondirectly from lookAt each frame. - Suppress OrbitControls rotation while locked (
controls.enableRotate = false); re-enable on disengage. Zoom (scroll wheel) remains functional viacontrols.enableZoom.
Map view behavior:
- Per-frame: if
state.viewLockTargetKeyis set, get target physics position via_getTargetPosition(id, type)(supportsbody,ship,station,jumpgate,lagrange,facility), convert to map scale, and setcontrols.targetto track the target. - On first engage: smooth transition (0.5s cubic ease-in-out) from current
controls.targetto target position. - After transition:
controls.targettracks 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
pingmessage withDate.now()timestamp every 30 seconds - Server echoes back as
pongwith 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
statemessage:ships.length + 1(other ships + self) - Incremented on
player_joinedmessage - Decremented on
player_leftmessage (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_classfield 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:
- 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”). - 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:
- 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
dis the distance from the body to its parent (planets → Sun, moons → planet). - If the ship is within a body’s SOI (
distance(ship, body) < r_SOI), that body is a candidate. - Among all candidates, select the one with the smallest SOI (most specific / deepest in hierarchy).
- 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 |
|---|---|
| 0° | 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 · n̂
sin_Ω = (ref × n̂) · 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) / μ - r̂
// Argument of periapsis from node and eccentricity vectors
cos_ω = n̂ · ê
sin_ω = (n̂ × ê) · ĥ
ω = 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_ν = ê · r̂
sin_ν = (ê × r̂) · ĥ
ν = atan2(sin_ν, cos_ν) // Convert to 0-360°
| True Anomaly | Position |
|---|---|
| 0° | 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
- Client opens WebSocket:
wss://galaxy.example.com/ws(no token in URL) - Client starts a 10-second connection timeout
- On open, client sends auth message:
{"type": "auth", "token": "<jwt>"} - Server validates token; on failure closes with code 4001
- Server sends
welcomewith player info — connection timeout is cleared - Server immediately sends first
statemessage with current game state - Server begins sending
stateupdates each tick - Client sends
pingevery 30 seconds; server responds withpong - If no
pongreceived 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 openreconnecting(yellow dot) — connection lost, backoff in progressdisconnected(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
errorresponse, 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
welcomemessage 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 (
disabledattribute) - 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 aTHREE.TextureLoaderinstance; 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 ornull(does not trigger loading)request(name, callback)— triggers load if state isidle; callscallback(texture)when ready (immediately if already loaded)isLoaded(name)— returnstrueif texture is inloadedstate_startLoad(name)— internal: callsTextureLoader.load(), setsSRGBColorSpaceon the loaded texture, transitions state toloaded, resolves all pending callbacks; on error transitions state tofailed(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
mousemoveandmouseuplisteners are added only during an active drag and removed on mouse-up - Bring-to-front on click: A
mousedownlistener on the entirewindowEl(not just the header) callsbringToFront(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-windowelements 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()andthis.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 ofelement.innerHTML = ''. - Structured HTML needed — build the DOM tree programmatically with
document.createElement()/element.appendChild()and set text viatextContent. - Never pass dynamic values through HTML string templates
(
`<tag>${value}</tag>`) intoinnerHTML.
Affected locations (fixed):
| File | What changed |
|---|---|
shipSystems.js — setupShipSystemsTooltips |
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