# Free Trader Orbit: physics

The engine behind the 3D Free Trader, an Earth–Moon trading game. It is pure
JavaScript with no DOM and no dependencies, deterministic, and tested in Node.
The career and renderer now use this engine. `game.js` owns flight anchors,
trade and saves; `worker.js` runs planning, prediction, node edits and flight
propagation off the scene thread. `orbit.js` draws the returned ship state.

## Files and loading

| File | What it holds |
|---|---|
| `physics/ephem.js` | constants, time, the Moon's ephemeris, the Sun's direction, sidereal time |
| `physics/kepler.js` | vectors, Kepler and Lambert solvers, orbital elements, Hohmann, plane change, the rocket equation |
| `physics/flight.js` | the ship, gravity, the integrator, finite burns, impact, docking, the seeded generator |
| `physics/stations.js` | the station catalogue |
| `physics/planner.js` | "plan for me", `flyPlan`, a quick Δv estimate |
| `physics/index.js` | Node only: all of the above as one object |
| `test-physics.js`, `test-planner.js` | tests (`node orbit/test-physics.js`, `node orbit/test-planner.js [--all]`) |

Each module is a classic-script IIFE. In Node it sets `module.exports`; in a
page or a worker it adds itself to `globalThis.OrbitPhysics`. In a page, load
`ephem.js`, `kepler.js`, `flight.js`, `stations.js` and `planner.js` in that
order as plain `<script>` tags. In a worker, `importScripts` them in the same
order. The planner runs in a worker. The renderer reads `globalThis.OrbitPhysics`
after these scripts load for station positions, frames and body orientation.

## Frames, time, units

- **Frame:** Earth-centred inertial, equatorial J2000 (x towards the J2000
  equinox, z towards the J2000 pole), the frame `sky.js` in the spike already
  uses. A test checks that the engine's Moon matches the spike's to 1.7 cm
  (the spike works in Julian days, which is the whole difference).
- **Time:** `t` is seconds since J2000.0 (JD 2451545.0). UTC is treated as
  TDB; the 69 s difference is ignored. `tOfIso`, `tOfMs`, `jdOfT` convert.
- **Units:** km, km/s, s, kg; thrust in N, Isp in s.
- **Public states are always Earth-centred.** Inside the Moon's sphere of
  influence the integrator works Moon-centred and converts on the way out.

## Models

**Earth.** A point mass, GM 398600.435436 km³/s² (JPL Horizons). J2
(1.08262668e-3, EGM96) is available with `j2: true` and is off by default:
with it on, every low orbit's plane swings round (the ISS's by −4.96 °/day in
the test, against −4.95 from secular theory), and the station reference
orbits would need the same drift. I left it for a later decision.

**Moon.** A point mass, GM 4902.800066 km³/s², moving on the Astronomical
Almanac's low-precision series (ecliptic longitude, latitude and horizontal
parallax), as ported in Transfer Window's `escape/sky.js`, with the series'
exact time derivative as its velocity. The Almanac gives it as good to about
0.3° in longitude, 0.2° in latitude and 0.003° in parallax: roughly 2,000 km
along the orbit and 1,000 km in distance. I fixed nothing in it. The game
does not need the real Moon to that accuracy; it needs one Moon, and every
flight and plan uses this one.

**The restricted problem.** The ship is massless. Earth-centred, the Moon
pulls as direct-minus-indirect (its pull on the ship minus its pull on the
Earth), the pattern in Transfer Window's `nbody.js`. Inside the Moon's
Laplace sphere of influence, a·(μm/μe)^(2/5) = 66,183 km, the state switches
to Moon-centred, with the Earth as direct-minus-indirect. The switch happens
only at an accepted step, as in Transfer Window's `centralFor`.

**What the frame switch costs.** The Moon-centred frame follows the
ephemeris, whose acceleration includes things the two-body model does not:
mostly the Sun's pull on the Moon, plus the truncation of the series. So the
force model jumps at the boundary by up to 3.7e-8 km/s², about 3% of the
Moon's gravity there (measured in the test). Adding the Sun's tide only
halves it, because the rest is the series itself. It is real physics in a
patched model, not a bug, but lunar encounters amplify it: a flyby passing
12,540 km from the Moon ends 663 km apart after three days, flown patched and
flown Earth-centred throughout, and a fall off L1 about as much. Plans and
flights share one model, so the game is consistent; the figure is the honest
size of the model's uncertainty near the Moon. `switchFrames: false` gives a
single consistent model for anyone who prefers that.

**Not modelled.** The Sun's gravity and tide; solar radiation pressure;
atmospheric drag; Earth harmonics beyond J2; the Moon's lumpy gravity
(mascons, which make real low lunar orbits decay); precession and nutation
(the frame is fixed J2000); light time; relativity; eclipses; the ship's
attitude (it points instantly); engine start-up, throttling or gimbal; any
perturbation of the stations (see below); the final metres of a docking.

## The integrator

Dormand–Prince 5(4) with adaptive step control and first-same-as-last,
ported from Transfer Window's `nbody.js` `integrate`: same tableau, same
error norm, same controller (0.9·err^−1/5, clamped to 0.2–5), and the same
cap of 0.15 dynamical times on a step. Two more caps: steps near the Moon
are limited so one cannot jump the sphere of influence, and integration is
split at every burn ignition and cut-off, so no step straddles one.

- Flight default: `rtol` 1e-12 (`atolPosition` 1e-8 km, `atolVelocity`
  1e-11 km/s). The planner solves at 1e-10, and a plan replays at the
  settings it was solved with (below).
- Energy drift over a 30-day two-body coast: 2.6e-9 in low orbit (470
  revolutions), 4.0e-10 in a transfer orbit, 6.5e-11 in a highly elliptical
  orbit. It scales with `rtol`: low orbit at 1e-11 drifts 2.6e-8, at 1e-10
  2.5e-7. The acceptance bound is 1e-8.
- Against universal-variable Kepler (pure two-body): 0.1 m after ten
  revolutions of a 24,000 km ellipse, 0.3 m after 30 days at GEO, 0.2 mm on
  a day-long hyperbola.
- Impact: a step that ends below the surface (plus an optional floor)
  stops the flight; so does a periapsis passed inside a step whose
  osculating radius is under the floor, provided both ends of the step lie
  on the periapsis side of the conic. That last condition stops far-off sign
  flips of r·v (a near-radial orbit pulled about by the Moon) from counting
  as an impact.

## Burns

Constant thrust T and Isp; mass flow ṁ = T / (Isp·g0) with g0 = 9.80665 m/s².
Within a burn the mass is exact, m(t) = m0 − ṁ(t − t0), so the integrator
sees a smooth thrust term (the old Free Trader's `advance` evaluated mass per
RK4 stage; same idea). A burn that would empty the tank is cut short and the
rest coasts, never producing negative propellant or free thrust. A burn holds
either a fixed inertial direction (`frame: 'inertial'`, how a manoeuvre node
is flown) or fixed [prograde, radial, normal] components in the orbit frame
of the Earth or the Moon (`frame: 'orbit'`). Prograde is along velocity,
normal along r × v, radial is prograde × normal (outward on a circle), the
convention in Transfer Window's `sky.js` `frame`.

Measured: in free space a burn's Δv equals ve·ln(m0/m1) to 5e-13; in low
orbit a 60 s prograde burn differs from the rocket equation by 0.14%
(gravity and steering, the acceptance bound is 0.5%). Plane change in the
impulsive limit: a burn of 2·v·sin(Δi/2) turns the plane by exactly Δi for
Δi of 5°, 28.5° and 46.4°.

**The default ship** (`SHIP`, `ENGINE`): 12 t dry, a 40 t tank, 220 kN,
Isp 450 s (hydrolox class, about two RL10s), so ve = 4.41 km/s and
ṁ = 49.9 kg/s. The career uses these masses and engine, a 20 t cargo hold,
and a 40 t propellant tank. Every physics function still accepts its own
engine and masses.

## Docking

`dockCheck(ship, target)`: within 2 km and closing slower than 5 m/s
(`DOCK`). The last metres of an approach are abstracted away. The planner
aims for 10 m and 1 cm/s, well inside that.

## Stations

Epoch 2026-10-01 00:00 UTC. Equatorial J2000 elements. The orbits echo real
ones; the nodes and phases are mine.

| id | Name | Orbit | Elements |
|---|---|---|---|
| `leo` | Low orbit | 420 km circular | i 51.6°, node 210°, u0 0° (ISS-like) |
| `polar` | Polar | 800 km circular | i 98°, node 210°, u0 90° (sun-synchronous class) |
| `geo` | Geostationary | 42,164.17 km | i 0°, over 75° E at the epoch (placed with IAU 1982 sidereal time) |
| `l1` | L1 gateway | Earth–Moon L1 | (1 − γ)·r_Moon, γ = 0.15093 (the restricted three-body quintic) |
| `llo` | Low lunar orbit | 100 km circular, Moon-centred | i 23.46°, node 0.0° (the Moon's equator from the IAU pole, NAIF pck00011) |

The polar station shares the low station's node, so their planes meet at the
smallest possible angle, 46.4°. A first draft had them 91° apart (10.6 km/s).

Stations are station-kept: they fly these closed-form paths exactly, cheap
enough to evaluate thousands of times in the planner. The ship flies the full
force model, and the planner closes the gap. L1 is unstable; the gateway
holds there by fiat, so a ship docked at L1 is moved with it, not flown.

## Plan for me

`plan(ship, targetId, opts)` returns finite burns that fly the ship from its
orbit, or from the station it is docked at (`ship.docked`), to a rendezvous
with the target station. The method follows the old Free Trader's
`planTransfer` (a Hohmann seed refined by shooting, every evaluation a full
propagation), ported to 3D and changed where 3D required it.

**Seeds.** Between Earth orbits, a Hohmann transfer from the line of nodes
(where the ship's plane crosses the target's) to the far side, with the plane
change split between the burns to minimise the total, or all at the second
burn (`split: 'apogee'`). The ship waits for the departure whose arrival
meets the station with the smallest timing error inside the horizon (two days
from Earth orbits, thirty from L1 or for the Moon). If none is close, it
arrives on the station's orbit anywhere and phases there. Lunar arrivals are
aimed on the approach hyperbola (the B-plane): periselene radius and plane
first, then insertion onto the station's orbit, then phasing. Departures from
L1 aim the perigee the same way. Low lunar orbit to L1 is the cheapest
Moon-centred Lambert arc found across a revolution of departures.

**Shooting.** Levenberg–Marquardt on seven unknowns (two burn vectors and the
arrival time) against six residuals (position and velocity at arrival).

- A half-revolution transfer leaves the out-of-plane miss uncontrollable by
  the burns: every plane through the departure point meets again at the far
  side. Only the arrival time reaches it, so the arrival time is an unknown.
- That leaves a one-parameter family of solutions (essentially, where the
  plane change happens). A seventh, lightly weighted residual, the total Δv,
  selects the cheapest member. It is then dropped so the rendezvous closes
  exactly.
- Burns are placed so their impulse centroid, not their midpoint, sits on the
  impulsive time: (m0 − (m0 − mf)/ln(m0/mf))/ṁ after ignition. Mass falls
  3.6× in a 5.6 km/s burn, and centring left hundreds of km of error.
- Where the two-body seed is poor, a continuation leads in: thrust scaled up
  (nearly impulsive burns) for long burns; the Moon's GM scaled from zero
  near the Moon; the Earth's tide scaled from zero around it. A stage that
  fails is halved, up to four times.
- A phasing orbit returns after whole revolutions, which is the same
  out-of-plane blind spot, so phasing ends with a short Lambert rendezvous
  (not a whole number of revolutions).
- A solve that stalls inside a tenth of the capture radius and speed is
  accepted. After an unstable point (L1), integration noise amplified over
  days sits above the 10 m aim.

**Rejection.** Every leg is flown once more with altitude floors (100 km over
the Earth, 10 km over the Moon by default). A leg that dips under one is
refused, and the next window is tried. A plan that finds no route returns
`{ ok: false, reason }` (for example "Trajectory dips to 420 km above the
Earth (floor 1000 km)"). Docked ships get two more attempts, each half a day
later.

**Output.** `{ ok, legs, burns, dv, prop, enough, depart, arrive, wait,
arrival, miss, check, flight, stats }`. Each burn has `t0`, `dt`, `dir`
(unit, inertial), `local` (the same as [prograde, radial, normal] at
ignition, for the node's handles), `dv`, `prop` and `mass`. `prop` is the
propellant for the cargo the ship carries; plan again with another cargo
(planning is cheap) or use `estimate(from, to, { cargo })` for pricing. When
`prop` exceeds the tank, `enough` is false, and the plan is still the
trajectory the ship would need.

**Replay.** The engine is deterministic: the same inputs give byte-identical
outputs. A plan records the integrator settings it was solved with
(`plan.flight`), and `flyPlan(ship, plan)` reproduces it exactly. That
matters for lunar routes. Re-flown at 100× tighter tolerance, Earth-orbit
plans still dock (metres), but plans through L1 or past the Moon end
kilometres to hundreds of km away, because those encounters amplify 1e-10
differences enormously. The game should fly plans with their own settings,
and treat any later disturbance (a player's edit) as a re-plan from where the
ship is. That near-station re-plan is fast (1–15 ms).

**Performance** (this Mac, Node 26): LEO → GEO plans in 6.3 ms median (warm;
17.5 ms on a cold first call), including the replay check, with 14
propagations. Phones are perhaps 3–5× slower, still far under the 200 ms
budget for a worker. Lunar routes take 0.1–0.7 s.

**Routes** on 2026-10-07, 5 t of cargo, docked at the start. The estimate is
the impulsive patched-conic `estimate()`. "Flight" runs from first ignition
to arrival, including any hold at L1 on the way.

| From → to | Δv (km/s) | Estimate | Wait | Flight | Legs | ms |
|---|---|---|---|---|---|---|
| leo → geo | 4.815 | 4.779 | 0.1 d | 0.23 d | transfer | 8 |
| leo → polar | 6.040 | 5.880 | 0.8 d | 0.04 d | transfer | 20 |
| leo → l1 | 4.036 | 3.929 | 14.8 d | 3.7 d | transfer | 144 |
| leo → llo | 4.393 | 4.093 | 13.7 d | 5.2 d | insertion + phasing | 534 |
| polar → geo | 5.978 | 5.951 | 0.1 d | 0.22 d | transfer | 8 |
| polar → llo | 4.255 | 4.017 | 0.0 d | 5.6 d | insertion + phasing | 498 |
| geo → leo | 4.790 | 4.779 | 0.3 d | 0.23 d | transfer | 9 |
| geo → l1 | 1.577 | 1.490 | 24.7 d | 4.4 d | transfer | 75 |
| geo → llo | 3.539 | 2.165 | 23.7 d | 5.7 d | insertion + phasing | 455 |
| l1 → leo | 4.015 | 3.929 | 4.2 d | 5.7 d | insertion + phasing | 156 |
| l1 → geo | 1.966 | 1.490 | 2.1 d | 5.6 d | insertion + phasing | 182 |
| l1 → llo | 0.851 | 0.725 | 0.0 d | 4.0 d | insertion + phasing | 204 |
| llo → l1 | 0.939 | 0.725 | 0.0 d | 2.0 d | transfer | 41 |
| llo → leo | 4.942 | 4.653 | 0.0 d | 9.9 d | via L1 | 229 |
| llo → geo | 2.904 | 2.214 | 0.0 d | 22.8 d | via L1 | 176 |

Verified again on 2026-10-08: over six dates from 2026-10 to 2028-02,
119 of the 120 station pairs plan and dock (`node orbit/test-planner.js --all`);
every Earth-orbit pair does. The
one refusal is L1 → low lunar orbit on 2027-08-03 (shooting did not converge).
The sweep requires exactly 119 docks and this specific exception; it no
longer accepts an arbitrary 95% pass rate.

**Known limits.**
- Low lunar orbit is left by way of L1. There is no direct trans-Earth
  injection yet.
- Lunar windows are geometric and can be long: from LEO the Moon crosses the
  51.6° plane twice a month (a 14.8-day wait on the reference date).
- The insertion burn pays for the angle between the approach and the
  station's plane. From GEO that is 1.2 km/s over the ideal. A mid-course
  correction cannot fix it: the approach line passes through the ship, which
  sits off that plane.
- Lambert solving is single-revolution only.
- The plane-change split is optimised per transfer, not across a whole route.

## Sources

- GM values: JPL Horizons headers (public domain).
- The Moon: Astronomical Almanac low-precision formulae, via Transfer
  Window's `escape/sky.js` (`moonKm`).
- The Sun's direction: Astronomical Almanac low-precision formulae, via the
  spike's `sky.js`.
- The Moon's pole: NASA NAIF `pck00011` (IAU WGCCRE 2015, public domain).
- Sidereal time: IAU 1982 (USNO short form).
- L1: the restricted three-body collinear-point quintic (Szebehely 1967;
  Koon, Lo, Marsden & Ross 2011).
- Integrator: Dormand & Prince (1980), via Transfer Window's `escape/nbody.js`.
- Kepler and Lambert: universal variables and Curtis, *Orbital Mechanics for
  Engineering Students*, Algorithm 5.2, via Transfer Window's
  `escape/transfer.js`.
- J2: Vallado, *Fundamentals of Astrodynamics and Applications*.
- Finite burns and shooting: the original Free Trader's `physics.mjs`
  (`advance`, `planTransfer`), planar, on drummer.

Luke Steuber, 2026.

## Career integration

Flight retains the anchor at each leg's ignition and propagates from that
anchor to each requested time using `plan.flight`. It does not accumulate
chunk-to-chunk integration differences. Station-kept holds ride the station
until ignition. Every intermediate held dock must pass the actual capture
check before the next leg; a disturbed trajectory cannot teleport there.
The terminal capture check gates cargo settlement. The render snapshots are
returned by the worker at approximately six simulation updates per second;
time rate and Next event change requested time, not the force model.

Prediction uses the same flight function on an isolated copy and retains the
exact closest-approach and arrival checks. Up to 700 accepted solver samples
are drawn. This is a sampled closest approach, not a continuous-time minimum
solver. Loaded flights reconstruct the remaining prediction from their saved
leg anchor and reload paused. Re-planning uses the actual current state.

Node controls specify prograde/radial/normal Δv in m/s at ignition about the
nearer body. The node converts that vector to a fixed inertial direction and
computes finite duration with the rocket equation. Numeric, tap, keyboard and
drag input modify the same burn. Display rounding does not alter untouched
values. Ignition must fit between neighbouring burns; the terminal burn may
extend arrival by up to one day from the original plan.
Edits may increase fuel beyond the tank, hit a surface or miss capture. The
planner never secretly repairs an edit during flight.

The funnel illustrates square-root potential depth, translated so Earth is
at the throat. Scene positions remain rigidly rotated Earth-centred vectors
at 1 scene unit per 1,000 km, with true body radii. No ship is moved onto the
potential surface. Halo and shape markers are visibility aids.
