Automation

Overview

Server-side rule engine that executes player-defined automation rules every tick. Rules consist of trigger conditions and actions that fire when conditions are met. Rules execute regardless of player connection status.

Data Model

Rule

Stored as a Redis hash at automation:{ship_id}:{rule_id}.

Field Type Description
rule_id uuid Unique rule identifier
ship_id uuid Owning ship
name string Display name (max 64 chars)
enabled string "true" or "false"
mode string "once" or "continuous"
priority integer 0-99, lower executes first
trigger_json string JSON-encoded trigger object
actions_json string JSON-encoded action array
created_at string ISO 8601 timestamp

Index

Redis set at automation:{ship_id}:rules containing rule_id strings.

Limits

Parameter Value
Max name length 64 characters
Max conditions per trigger 5
Max actions per rule 5

Trigger Format

{
  "conditions": [
    {"field": "ship.fuel", "op": "<", "value": 0.05},
    {"field": "ship.distance_to", "op": "<", "value": 1000000, "args": ["Earth"]}
  ],
  "logic": "AND"
}

Trigger Fields

Field Type Range Description
ship.fuel float 0.0-1.0 Fuel fraction (fuel / fuel_capacity)
ship.thrust float 0.0-1.0 Current thrust level
ship.speed float 0+ m/s Speed relative to reference body (SOI/Hill sphere), matching HUD display
ship.surface_speed float 0+ m/s Speed relative to the surface of the reference body, accounting for body rotation. Useful for landing triggers.
ship.agl float 0+ m Altitude Above Ground Level. Uses terrain elevation when available (e.g. Luna), otherwise altitude above body radius. Computed by physics service from nearest body. 0 when landed.
ship.distance_to float 0+ m Distance to named body (requires args: ["BodyName"])
orbit.apoapsis float 0+ m Apoapsis altitude above surface (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories.
orbit.periapsis float m Periapsis altitude above surface (optional args: ["BodyName"], defaults to reference body). Can be negative (impact trajectory).
orbit.eccentricity float 0+ Orbital eccentricity (optional args: ["BodyName"], defaults to reference body). 0=circular, <1=elliptical, ≥1=escape.
orbit.inclination float 0-180° Orbital inclination relative to body’s equatorial plane (optional args: ["BodyName"], defaults to reference body).
orbit.period float 0+ s Orbital period in seconds (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories.
orbit.true_anomaly float 0-360° True anomaly in degrees (optional args: ["BodyName"], defaults to reference body).
orbit.angle_to_pe float 0-180° Angular distance to periapsis in degrees (optional args: ["BodyName"], defaults to reference body). Returns false for near-circular orbits (e < 0.001).
orbit.angle_to_ap float 0-180° Angular distance to apoapsis in degrees (optional args: ["BodyName"], defaults to reference body). Returns false for escape trajectories or near-circular orbits (e < 0.001).
orbit.angle_to_an float 0-180° Angular distance to ascending node in degrees (optional args: ["BodyName"], defaults to reference body). Returns false when inclination ≤ 0.5° or ≥ 179.5°.
orbit.angle_to_dn float 0-180° Angular distance to descending node in degrees (optional args: ["BodyName"], defaults to reference body). Returns false when inclination ≤ 0.5° or ≥ 179.5°.
ship.fuel_pct float 0-100 Fuel as percentage of capacity (fuel / fuel_capacity × 100)
ship.docked integer 0 or 1 Whether ship is currently docked (1) or not (0)
ship.docked_station string name or “” Station name the ship is docked at (e.g. “Gateway Station”). String field — only == and != operators. Value must be a string, not a number. Empty string when not docked.
game.tick integer 0+ Current game tick number
immediate integer 1 Always returns 1. Use with once mode to fire a rule on the next tick.

All fields are read from Redis ship and body hashes. ship.speed is computed relative to the reference body determined by SOI (Hill sphere) containment — the same algorithm used by the HUD and physics engine. This ensures automation rules trigger at the orbital speed players see, not the absolute ICRF velocity. Falls back to absolute speed if no reference body is found.

Orbital element fields (orbit.*) are computed lazily — only when a rule references them. Results are cached per body name within a single tick evaluation to avoid recomputation when multiple conditions reference the same body. Orbital elements use the same Keplerian math as the HUD display (Python port of calculateOrbitalElements).

Comparators

Operator Description
< Less than
> Greater than
<= Less than or equal
>= Greater than or equal
== Equal (exact float comparison)
!= Not equal

Logic

Only AND is supported in MVP. All conditions must be true for the trigger to fire.

Action Format

[
  {"action": "set_thrust", "value": 0},
  {"action": "set_attitude", "value": "prograde"},
  {"action": "alert", "message": "Fuel critical!"}
]

Actions

Action Parameters Description
set_thrust value (float 0.0-1.0) Set ship thrust level via gRPC ApplyControl
set_attitude value (string: prograde, retrograde, normal, antinormal, radial, antiradial, hold, none) Set attitude mode via gRPC SetAttitudeMode/SetAttitudeHold. Hold mode uses PD tracking toward attitude_target (not just rate damping). When activated by player (H key), current attitude is saved as target so the ship holds its current orientation. When set externally with a target quaternion (e.g., by rendezvous), the ship steers toward that direction.
circularize (none) Start autonomous circularization maneuver relative to current reference body
set_inclination value (float, target degrees), optional args: ["BodyName"] Start autonomous inclination change maneuver. Defaults to current reference body if no args.
rendezvous target_id (uuid or lagrange key), target_type (“ship”, “station”, “jumpgate”, or “lagrange”), optional strategy, optional dock_on_arrival (bool), optional max_fuel_fraction (float 0.0–1.0), optional max_transfer_time (number, seconds), optional budget_mode (string) Start autonomous rendezvous maneuver with target entity in same SOI. Strategy selects transfer approach (see below). Default: "hohmann". When dock_on_arrival is true (default for stations), automatically docks at the target upon completion. max_fuel_fraction limits fuel usage to a fraction of fuel capacity (brachistochrone only). max_transfer_time specifies maximum transfer time in seconds; the solver finds the minimum fuel needed (brachistochrone only). budget_mode selects an automatic budget computation: "efficient" computes the Hohmann transfer time from current orbital geometry and uses it as max_transfer_time, yielding the minimum fuel for the most time-efficient ballistic-equivalent transfer (brachistochrone only). For target_type: "lagrange", target_id is a key string like "Sun-Earth-L1" (not a UUID). Position and velocity are computed on-demand from body data using Hill’s approximation; velocity is the co-rotating velocity at the Lagrange point.
station_keep target_id (uuid, lagrange key, or body name), target_type (“ship”, “station”, “jumpgate”, “lagrange”, or “body”) Start autonomous station-keeping maneuver. Captures current distance to target as hold_distance_m at activation. Holds position within adaptive deadband of the hold distance. Never completes — must be aborted.
dock target_id (uuid) Dock with specified station. Calls physics RequestService(SERVICE_DOCK). Fails silently if ship is already docked, out of range, or too fast.
undock (none) Undock from current station. Calls physics RequestService(SERVICE_UNDOCK). Fails silently if ship is not docked.
set_raan value (float, target degrees), optional args: ["BodyName"] Start autonomous RAAN change maneuver. Defaults to current reference body if no args.
set_orbit_plane target_inclination (float, degrees), target_raan (float, degrees) Start combined inclination + RAAN change maneuver using Q-law OOP steering.
land target_body (string, body name), optional target_lat (float, degrees), optional target_lon (float, degrees) Start autonomous landing maneuver on target body. With lat/lon, performs targeted landing with plane change + precision descent (0.5m accuracy). Ship must be in the body’s SOI with eccentricity < 1.
shuttle station_a (uuid), station_b (uuid), optional strategy (“efficient”, “balanced”, “fastest”) Repeating dock-to-dock delivery between two stations. Undocks, transits, docks, refuels to 100%, then returns. Runs until cancelled. Default strategy: “balanced”.
alert message (string, max 128 chars) Send status message to player via automation_triggered event

Rule Modes

Mode Behavior
once Rule auto-disables (enabled=”false”) after first successful firing
continuous Rule fires every tick while conditions are met

Copying Rules

Players can copy automation rules between ships they own.

WebSocket API

Request: automation_copy

Field Type Description
source_ship_id uuid Ship to copy rules from
target_ship_id uuid Ship to copy rules to
rule_ids array of uuid Rule IDs to copy (empty array = all rules)

Response: automation_copied

Field Type Description
source_ship_id uuid Source ship
target_ship_id uuid Target ship
rules array Newly created rule objects
count integer Number of rules copied

Behavior

  • Both source and target ships must be owned by the player
  • Copied rules get new UUIDs
  • Copied rules are created with enabled: false so they don’t fire immediately
  • Name conflicts: if target ship has a rule with the same name, append “ (2)”, “ (3)”, etc.

Evaluation Algorithm

Evaluation runs once per tick in the tick-engine, after ProcessTick() completes:

  1. Discover ships with rules: scan Redis for automation:*:rules keys
  2. For each ship with rules: a. Load ship hash from Redis b. Load all enabled rules, sorted by priority (ascending) c. Build evaluation context (fuel fraction, speed, thrust, etc.) d. For each rule:
    • Evaluate all conditions against context
    • If all conditions true (AND logic):
      • Execute actions in order
      • If mode is once, disable the rule
      • Publish automation.triggered event to galaxy:automations stream
  3. Body positions are loaded once at the start of evaluation (cached across all ships)

Performance

  • Only ships with non-empty rule sets are evaluated
  • Body positions loaded once per tick, not per-ship
  • No recursive triggers: actions from automation do not re-trigger rules in the same tick

Maneuvers

Maneuvers are autonomous multi-tick operations that execute attitude and thrust commands each tick until a completion condition is met. Only one maneuver per ship at a time — starting a new maneuver cancels any active one.

Maneuver State

Stored as a Redis hash at maneuver:{ship_id}:

Field Type Description
type string "circularize", "set_inclination", "rendezvous", "station_keep", or "land"
ref_body string Reference body name
rule_id string Rule that started this maneuver
rule_name string Rule display name
started_tick integer Tick when maneuver began
started_game_time string ISO 8601 game time when maneuver began
target_inclination float Target inclination in degrees (set_inclination only)
target_id string Target entity ID: UUID for ship/station/jumpgate, key string for lagrange (e.g. "Sun-Earth-L1"), or body name (rendezvous/station_keep)
target_type string "ship", "station", "jumpgate", "lagrange", or "body" (rendezvous/station_keep)
dock_on_arrival string "true" or "false" (rendezvous only). When true, automatically sends a dock request to the target station upon rendezvous completion. Defaults to "true" for dockable targets (stations), "false" otherwise.
strategy string Transfer strategy: "hohmann", "fast", "express", "parabolic", or "brachistochrone" (rendezvous only). Legacy value "manual" maps to "hohmann".
phase string Current phase: rendezvous uses "plane_change", "transfer_plan", "departure_wait", "transfer_burn", "transfer_coast", "circularize", "phase_coast", "phase", "brachistochrone", "capture", or "approach"; station_keep uses "approach" or "hold"
hold_distance_m float Distance to maintain from target in meters (station_keep only). Captured from current distance at activation time.
status_text string Human-readable description of current maneuver activity, updated each tick. Examples: "Circularizing — prograde, e=0.042", "Plane change — oop, eff 0.85". Empty string when unavailable.
element_errors string JSON array of element error objects for client display. Each object: {"label": "Δi", "value": "2.1°", "ok": true/false}. ok is true when the element is within convergence tolerance. The client renders converged elements in green and out-of-tolerance elements in red. Empty string when unavailable.
abort string "true" when abort requested (set by API gateway)
paused string "true" or "false" (default "false") — when true, tick-engine skips maneuver logic but preserves all state
paused_at_tick integer Tick when maneuver was paused (cleared on resume)

Maneuver Lifecycle

  1. Start: Rule action creates maneuver state in Redis, sets initial attitude + thrust
  2. Running: Each tick after rule evaluation, active maneuvers are checked and advanced:
    • Check for abort flag first — if abort == "true", abort immediately (see below)
    • Check for paused flag — if paused == "true", skip maneuver logic entirely (state preserved, ship coasts)
    • Early completion check for landing: If maneuver type is land and landed_body_name is set, complete immediately — landed ships have zero relative velocity which makes orbital elements undefined
    • Compute orbital elements relative to reference body (returns null for degenerate orbits, e.g., zero velocity)
    • If orbital elements are unavailable and not already handled by early checks, skip this tick
    • Adjust attitude (prograde/retrograde for circularize, normal/antinormal for inclination)
    • Thrust only when attitude is aligned with desired direction (see Alignment Check)
  3. Complete: When completion threshold is reached:
    • Set thrust to 0
    • Set attitude hold
    • If dock_on_arrival == "true" and target_type == "station": send dock request to physics service via RequestService(SERVICE_DOCK). Dock failure is non-fatal — maneuver still completes and player is notified via automation_triggered event.
    • Clear maneuver from Redis
    • Publish automation.maneuver_complete event
  4. Abort: When abort field is "true" (set by API gateway via maneuver_abort message):
    • Set thrust to 0
    • Set attitude hold
    • Clear maneuver from Redis
    • Publish automation.maneuver_aborted event

Alignment Check

All maneuvers use a binary alignment gate before thrusting: if the ship’s forward vector (attitude quaternion applied to +Z) is more than ALIGNMENT_GATE_DEG (5°) from the desired thrust direction, thrust is set to zero. When aligned within the gate, full commanded thrust is applied. This eliminates the fuel efficiency losses from the previous cosine scaling approach, where thrust *= cos(angle) caused continuous fuel waste during reorientation — the effective exhaust velocity dropped to v_e × cos(angle), a single-cosine loss that the BCB planner couldn’t budget for.

With the alignment gate, the cost of reorientation is time-based (dead time during slews) rather than efficiency-based, and is analytically predictable:

  • T_settle = 4/ω_n per direction change (8s for fast_frigate at ω_n=0.5, 27s for cargo_hauler at ω_n=0.15)
  • The BCB planner deducts dead time from effective burn time (see Brachistochrone section)
  • At 5°, cos(5°) = 0.996 — effectively zero efficiency loss when thrusting

status_text indicates the aligning state when outside the gate (e.g., "Circularizing — aligning (15°)"). This prevents off-axis burns that waste fuel and push the orbit in the wrong direction — especially critical when circularize switches between prograde and retrograde. This applies to all maneuver thrusting including the periapsis guard.

Constant Value Description
ALIGNMENT_GATE_DEG Zero thrust beyond this angle; full thrust within — replaces cosine scaling

Completion Thresholds

Maneuver Threshold Description
circularize eccentricity < 0.001 Near-circular orbit achieved
set_inclination |inclination - target| < 0.5° Target inclination reached
rendezvous distance < 500 m AND relative velocity < 1 m/s Proximity and velocity match achieved
station_keep (never completes) Holds distance until aborted
land landed_body_name != "" Ship has touched down on surface

Circularize Algorithm

Uses vector-based delta-v targeting. Computes the desired circular velocity vector (tangential, at v_circ = sqrt(GM / r)) and burns toward the required delta-v direction to correct both speed magnitude and direction simultaneously.

Each tick:

  1. Compute relative position and velocity vectors to reference body
  2. Decompose velocity into radial (v_r) and tangential (v_t) components
  3. Compute desired circular velocity: v_circ = sqrt(GM / r), directed tangentially (in the current orbital plane, perpendicular to radius, in the direction of angular momentum)
  4. Compute delta-v vector: dv = v_desired - v_current (kills radial velocity and corrects tangential to v_circ)
  5. Set attitude to burn in the delta-v direction using ATTITUDE_DIRECTION mode
  6. Compute proportional throttle: thrust = min(1.0, |dv| / dv_per_tick) where dv_per_tick = max_thrust / total_mass * effective_dt
  7. Apply alignment gate: if alignment_angle > ALIGNMENT_GATE_DEG (5°), set thrust to 0

This replaces the prior scalar speed comparison (|v| vs v_circ) which failed when speed magnitude matched circular velocity but direction was wrong (e.g., mostly radial velocity).

Set Inclination Algorithm

Each tick:

  1. Compute orbital inclination relative to reference body’s equatorial plane
  2. If inclination > target: set attitude antinormal (decrease inclination)
  3. If inclination < target: set attitude normal (increase inclination)
  4. Set thrust to 1.0

Rendezvous Algorithm

State machine: PLANE_CHANGE → TRANSFER_PLAN → DEPARTURE_WAIT → TRANSFER_BURN → TRANSFER_COAST → CIRCULARIZE → [PHASE_COAST → TRANSFER_PLAN] → PHASE → APPROACH → COMPLETE. See Q-law spec for steering math details (GVE, periapsis barrier, effectivity).

Transfer Strategies

Strategy Factor Description
hohmann Iterative Hohmann burns at apsides. Lowest Δv, longest transfer. Default.
fast Two-burn transfer. Ascending: 2× apogee. Descending: perigee at r_target/2. Moderate Δv increase, ~50% time reduction.
express Two-burn transfer. Ascending: 5× apogee. Descending: perigee at r_target/5. High Δv, ~80% time reduction.
parabolic ∞ (escape) Two-burn transfer via parabolic trajectory (e=1). Maximum Δv, fastest transfer.
lambert Lambert solver: direct point-to-point transfer using position vectors. Computes optimal velocity vectors for given time of flight.
brachistochrone Direct continuous-thrust trajectory. Skips orbit matching entirely — accelerates toward target, flips, decelerates to arrive. Fastest for high-thrust ships at close range.

Backward compatibility: The legacy value "manual" is mapped to "hohmann" at maneuver start. Existing rules with strategy: "manual" continue to work unchanged.

UI dropdown options (rendezvous strategy selector):

Value Label Notes
hohmann Hohmann (efficient) Default. Shows Δv/time estimate.
fast Fast (2× apogee) Shows Δv/time estimate.
express Express (5× apogee) Shows Δv/time estimate.
parabolic Parabolic (fastest) Shows Δv/time estimate.
brachistochrone Brachistochrone (direct) Shows coast ratio, fuel budget rows. No Δv estimate (depends on thrust profile).

Coast ratio and fuel budget rows are shown only when brachistochrone is selected.

Budget mode dropdown (brachistochrone only):

Value Label Notes
none None Uses all available fuel, no constraints.
fuel Fuel % Shows fuel budget input (% of capacity).
time Max time Shows max transfer time input (hours).
efficient Efficient Auto-computes Hohmann transfer time as max_transfer_time, minimizing fuel for equivalent speed.
fastest Fastest Optimizes for minimum transfer time. Cross-SOI: scans aggressive departure windows. Same-SOI: uses all fuel for maximum burn.

Optimal Departure Timing

For Hohmann transfers from eccentric orbits (e > 0.01), an optional departure scan finds the optimal departure position to minimize total Δv (Oberth effect). Scans 12 candidate true anomaly positions over 2 orbits and selects the minimum-Δv departure if it saves > 5% over immediate departure. This is most beneficial when departing from eccentric parking orbits where periapsis gives significantly lower Δv.

Bi-Elliptic Transfers

For very large altitude changes (radius ratio > 11.94:1), a bi-elliptic transfer with an intermediate apoapsis at BI_ELLIPTIC_FACTOR × max(r1, r2) can save > 5% Δv over a standard Hohmann. Implemented as two sequential Hohmann transfers through the existing state machine (cycle 1: r1 → r_intermediate, cycle 2: r_intermediate → r2), requiring no new sub-states. The should_use_bi_elliptic() function evaluates whether the savings threshold is met.

Unified Transfer State Machine

Architecture: Phase Handler Extraction

The _maneuver_rendezvous_manual_tick method is structured as a dispatcher + phase handlers pattern:

  1. Dispatcher (_maneuver_rendezvous_manual_tick): ~200 lines. Loads target data, computes shared state (relative position/velocity, orbital elements, tolerances, GVE coefficients), performs distance-based approach transition, then delegates to the appropriate phase handler.

  2. Shared context (_RvContext dataclass): Groups all shared state computed by the dispatcher — ship/target/body positions and velocities, orbital elements, tolerances, GVE coefficients, element diffs, periapsis barrier params, Q-law class params, physics stub, tick, and helper closures (_build_element_errors, _apply_steering). Passed as a single argument to each handler.

  3. Phase handlers: Each phase is an async method on AutomationEngine:

Method Phase(s) Typical Lines
_phase_plane_change plane_change ~130
_phase_transfer_plan transfer_plan ~260
_phase_departure_wait departure_wait ~190
_phase_transfer_burn transfer_burn ~105
_phase_transfer_coast transfer_coast ~180
_phase_circularize circularize ~390
_phase_phase_coast phase_coast ~150
_phase_phase phase ~450
_phase_brachistochrone brachistochrone delegate
_phase_approach approach delegate

Phase dispatch uses a dict mapping phase names to handler methods. Each handler receives (self, ship_id, maneuver, ctx) where ctx is the _RvContext. Handlers may mutate maneuver and call _transition_phase for phase transitions. Some handlers (e.g., plane_change) fall through by returning "continue" to let the dispatcher invoke the next phase in the same tick.

Phase Transition Helper

All phase transitions go through a single helper method to ensure consistent persistence and logging:

async def _transition_phase(
    self, ship_id: str, maneuver: dict, new_phase: str, tick: int,
    status_text: str | None = None, **updates,
) -> None:

The helper:

  1. Sets maneuver["phase"] = new_phase
  2. Sets maneuver["status_text"] if status_text is provided
  3. Applies any additional field updates from **updates via maneuver.update()
  4. Calls await self._tick_state.set_active_maneuver(ship_id, maneuver) to persist

Phase handlers call _transition_phase instead of directly mutating maneuver["phase"] and calling set_active_maneuver separately. This eliminates the class of bugs where persistence or field updates are forgotten after a phase assignment.

The centralized phase transition telemetry in _process_target_sync_maneuver (which detects pre_phase != post_phase and logs to PostgreSQL + Redis stream) remains unchanged — it provides the canonical event recording for all transitions regardless of how they occur.

Guard Clause Style

Phase handlers and action dispatch use guard clauses (early returns) to keep the main logic path at low nesting depth (target: max 3 levels inside a method body). Conditions that would push nesting deeper are inverted and returned early:

# Instead of:
if complex_condition:
    if another_condition:
        [main logic at depth 3+]

# Use:
if not complex_condition:
    return
if not another_condition:
    return
[main logic at depth 1]

This applies to: parameter validation in _execute_actions, orbital condition checks in phase handlers (_phase_transfer_plan, _phase_phase, _phase_circularize), and fuel/thrust cascading guards in _brachistochrone_tick.

Position/Velocity Extraction Helpers

Module-level helper functions eliminate duplicated Redis hash unpacking throughout automation.py:

def _extract_pos_vel(data: dict) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
    """Extract (position, velocity) tuples from a Redis hash dict."""
    pos = (float(data.get("position_x", 0)),
           float(data.get("position_y", 0)),
           float(data.get("position_z", 0)))
    vel = (float(data.get("velocity_x", 0)),
           float(data.get("velocity_y", 0)),
           float(data.get("velocity_z", 0)))
    return pos, vel

def _extract_pos(data: dict) -> tuple[float, float, float]:
    """Extract position tuple from a Redis hash dict."""
    return (float(data.get("position_x", 0)),
            float(data.get("position_y", 0)),
            float(data.get("position_z", 0)))

Used wherever ship, body, or target data is read from Redis hashes (ship:{id}, body:{name}, station:{id}). Replaces the repeated 3-line or 6-line float(data.get("position_x", 0)) pattern. Applied in: compute_soi_radius, _process_target_sync_maneuver, _maneuver_rendezvous_manual_tick (dispatcher), cross-body transfer handling, _find_reference_body, _build_context, _get_orbital_elements, _evaluate_condition.

Module-Level Constants

All tunable parameters and repeated literals are defined as module-level constants at the top of automation.py. This provides single-point-of-change for gameplay balancing and self-documenting code.

Ship defaults (fallback values when ship class lookup fails or context is unavailable):

Constant Value Description
DEFAULT_SHIP_CLASS "fast_frigate" Default ship class for thrust/ISP/RCS lookups
DEFAULT_SHIP_DRY_MASS 20,000 kg Default dry mass when context unavailable
DEFAULT_MAX_THRUST 500,000.0 N Fallback max thrust for SHIP_CLASS_THRUST.get()
DEFAULT_ISP 20,000.0 s Fallback specific impulse for SHIP_CLASS_ISP.get()
DEFAULT_RCS_THRUST 4,000.0 N Fallback RCS thrust for SHIP_CLASS_RCS_THRUST.get()

Physical constants:

Constant Value Description
_G 6.6743e-11 Gravitational constant (m³ kg⁻¹ s⁻²)
STANDARD_GRAVITY 9.80665 m/s² Standard gravitational acceleration (for Tsiolkovsky equation)

Unit conversion and communication:

Constant Value Description
METERS_PER_KM 1,000 Meters-to-kilometers conversion factor
GRPC_TIMEOUT_S 2 gRPC call timeout (seconds) for physics service calls

Telemetry and diagnostics:

Constant Value Description
TELEMETRY_LOG_INTERVAL_TICKS 300 Periodic telemetry logging interval (ticks)

Time-scale invariance: All transfer states must produce correct results regardless of the game time scale. Burns that depend on orbital position (e.g., Hohmann burn at perigee, circularize at apoapsis) must gate on true anomaly windows rather than burning continuously. This prevents high time scales from causing burns at incorrect orbital positions.

All transfer strategies (Hohmann, fast, express, parabolic) use a single explicit state machine for orbit-to-orbit transfers. The flow is:

plane_change → transfer_plan → departure_wait → transfer_burn →
transfer_coast → circularize → [phase_coast → transfer_plan] →
phase → approach → complete
Transfer States
State Purpose Exit Condition
transfer_plan Compute transfer orbit, departure angle, burn Δv values Immediate (one tick) → departure_wait
departure_wait Coast until phase angle matches ideal departure \|phase_err\| ≤ 2°transfer_burn
transfer_burn Execute burn 1 (prograde). Hohmann waits for perigee; high-energy burns immediately dv_rem < 0.1 m/stransfer_coast
transfer_coast Coast on transfer ellipse. OOP corrections during coast Hohmann: near apoapsis (ν ≈ π). High-energy: radius crosses target → circularize
circularize Execute burn 2. Hohmann: prograde at apoapsis. High-energy: combined tangential+radial dv_rem < 5 m/s → evaluate next state
phase_coast Coast in phasing orbit while differential mean motion closes phase angle \|Δφ\| < 2°transfer_plan (re-enter for real target)

The plane_change, phase, and approach states are unchanged from the original design.

Burn Calculations (compute_full_transfer)

Unified function compute_full_transfer(r_departure, r_target, mu, strategy, a_ship, e_ship) parameterizes burn geometry for all strategies:

Hohmann (apogee factor = 1×):

  • transfer_a = (r_departure + r_target) / 2
  • dv1 = √(μ(2/r_dep - 1/transfer_a)) - √(μ(2/r_dep - 1/a_ship)) (prograde at perigee)
  • dv2 = √(μ/r_target) - √(μ(2/r_target - 1/transfer_a)) (prograde at apoapsis)
  • transfer_time = π × √(transfer_a³/μ)
  • burn_1_point = "perigee", nu_arrival = π

Fast (factor = 2×), Express (5×):

  • Direction-aware orbit geometry:
    • Ascending (r_departure ≤ r_target): r_perigee = r_departure, r_apogee = factor × r_target. Ship departs at periapsis, crosses target on outbound leg.
    • Descending (r_departure > r_target): r_apogee = r_departure, r_perigee = r_target / factor, clamped to body_radius × 1.01 (defaults to r_target × 0.5 when body_radius unavailable). This prevents periapsis below the body surface. Near-Hohmann fallback: if the clamped periapsis is within 5% of target (r_perigee > 0.95 × r_target), the orbit is essentially Hohmann — falls back to compute_full_transfer(..., "hohmann") which uses apse-based burns and detection (more robust for near-surface targets). Ship departs at apoapsis, crosses target on inbound leg. dv1 < 0 (retrograde).
  • transfer_a = (r_perigee + r_apogee) / 2
  • burn_1_point = "any" (burn immediately)
  • dv2 = vector difference at r_target (tangential + radial components)
  • Transfer time: ascending = time from ν=0 to ν₁; descending = time from ν=π to ν₂ (by symmetry: (π - M) / n)

Parabolic (e=1):

  • Departure at escape velocity, burn_1_point = "any"
  • dv2 = vector difference at r_target (tangential + radial components)
transfer_plan State

On first entry (cycle 1):

  1. Check for pre-computed phasing orbit: if transfer_is_phasing == "true" and transfer_target_r is already set (by the phase handler’s computed-phasing logic), use that target directly. The phase handler uses multi-orbit phasing with altitude-scaled max_deg, which handles phase angles > 60° that transfer_plan’s single-orbit limit cannot.
  2. Same-altitude detection: if Δa < a_tol (ship and target at same orbit) AND Δφ > 2°, compute a multi-orbit phasing orbit using altitude-scaled max_deg (n_orbits = ceil(angle/max_deg), where max_deg = min(120, 30 × max(1, T_target/5500))) instead of a degenerate same-altitude Hohmann (which would have dv ≈ 0 and never close the phase angle). This prevents the same-altitude routing loop. The altitude scaling matches the phase handler — at LEO 30°/orbit, at high orbits up to 120°/orbit.
  3. Otherwise, compute phase angle to target and ideal departure phase for direct transfer
  4. If ship is near the target orbit (|Δa| < 5 × a_tol, ensuring only same-altitude or near-same-altitude triggers phasing) AND phase error is moderate (5° < err < 60°) AND Δφ > 2°: compute phasing orbit SMA, set transfer_is_phasing = "true", target phasing orbit radius. For errors > 60°, waiting for the departure window is cheaper than a large phasing orbit. For different-altitude transfers (ship far from target orbit), departure timing alone handles the phase — the Hohmann transfer time provides natural phase drift, and departure_wait waits for the correct departure window.
  5. Otherwise: target real orbit directly

On re-entry after phase_coast (cycle 2):

  • Always target real orbit, transfer_is_phasing = "false"

Calls compute_full_transfer(), stores all transfer parameters, transitions to departure_wait.

Departure radius: For phasing transfers (is_phasing = true), the departure radius is the ship’s SMA (not instantaneous radius). The ship just circularized and is near-circular, but r_mag oscillates around the SMA due to residual eccentricity. When the phasing orbit SMA is close to the ship’s SMA (typical for small phase angles), a momentary r_mag > r_target_orbit flips the Hohmann from ascent to descent. Descent arrival at perigee (ν≈0) instead of apoapsis (ν≈π) causes the circularize to converge at the wrong altitude, leaving a highly eccentric orbit with correct SMA but wrong apsides. Using SMA as the departure radius is stable across the orbit.

departure_wait State

Each tick:

  1. Compute current phase angle and phase error vs ideal departure
  2. Immediate departure: depart immediately (skip departure wait) when any of these conditions hold:
    • transfer_is_phasing == "true" — phasing orbit transfer. The exact arrival phase is unimportant because phase_coast closes the remaining angle. This avoids both the same-altitude stall (zero differential drift means the ideal departure window never naturally arrives) and the active-burn drift problem (prograde burns during departure_wait change the orbit enough to drift the phase angle ~90°, making the subsequent coast much longer).
    • transfer_cycle == "2" — return from phasing orbit to target orbit. The phase coast already closed the angle to within 2°. Waiting for the ideal departure is counterproductive because active burns during the wait change the orbit (e.g. raising from 200km to 1200km), which causes the return Hohmann to arrive with a large phase offset. Immediate departure preserves the phase coast’s work.
    • departure_immediate == "true" — orbit correction transfer from the phase handler. The correction is fixing orbit shape (not phase timing), so departure timing doesn’t matter. Active burns would change the orbit further, defeating the correction’s purpose.
  3. For non-immediate transfers: If |phase_err| ≤ 2° OR phase error crossed zero since last tick (handles high time scales where phase_err jumps over the window): recompute transfer plan at current radius (including transfer_burn_point from the plan’s burn_1_point), transition to transfer_burn. Stores departure_phase_prev for zero-crossing detection.
  4. Otherwise compute ETA from differential mean motion:
    • If eta > min(DEPARTURE_ACTIVE_WAIT_ORBITS × T_target, DEPARTURE_ACTIVE_WAIT_MAX_S): active departure wait — apply gentle pro/retrograde burns (same phasing logic as the phase state: proportional throttle, per-tick Δv cap, SMA headroom limiter) to create a temporary SMA offset that accelerates phase drift. The min() ensures high orbits (where 3 periods may exceed 70h) still activate within 2h game time. This prevents multi-hour stalls when ship and target orbits are nearly identical (e.g. during Hohmann correction cycles).
    • Orbit-relative SMA headroom: phase_headroom = max(a_tol × PHASE_SMA_HEADROOM_FACTOR, r_target × DEPARTURE_HEADROOM_MIN_FRACTION). The a_tol-based headroom (500 km at GEO) is only 1.2% of orbit radius at high altitudes, producing a phase drift of ~6.4°/day — too slow for large phase angles. The orbit-relative floor (5% of target orbit radius = 2,108 km at GEO) gives ~27°/day drift, closing 150° in ~960s at 500× time scale. At LEO the a_tol-based value dominates, so behavior is unchanged.
    • Otherwise: passive wait — zero thrust, pre-orient prograde
transfer_burn State

Orbit-targeted burn — recomputes needed Δv from current state each tick (vis-viva), making the burn time-scale independent. Finite-burn effects (thrust applied in a fixed inertial direction while sweeping an orbital arc) are automatically corrected because each tick steers toward the target orbit shape.

Each tick:

  1. Compute v_target = sqrt(μ * (2/r - 1/a_transfer)) — vis-viva target speed for the transfer orbit at current radius
  2. Compute dv_needed = v_target - v_tangential (positive = prograde, negative = retrograde)
  3. Exit condition: |dv_needed| < 0.5 m/s OR orbit shape reached target:
    • High-energy (burn_point = "any"): SMA convergence — |a_current - a_transfer| / a_transfer < 0.02 (2%). For parabolic transfers where a_transfer is non-finite, falls back to |a_current - transfer_target_r| / transfer_target_r < 0.02. This handles same-altitude fast/express transfers where the ascent/descent checks would immediately pass (apoapsis already at target) without any burn occurring.
    • Ascent (burn_point = "perigee"): apoapsis ≥ 99% of transfer_target_r
    • Descent (burn_point = "apoapsis"): periapsis ≤ 101% of transfer_target_r
    • If transfer_burn_point is missing, derive from transfer_dv1: negative = descent (apoapsis), positive = ascent (perigee)
  4. Burn prograde/retrograde toward target speed: thrust_fraction = min(1.0, |dv_needed| / dv_per_tick)
  5. On exit: transition to transfer_coast, store transfer_r_prev
Ascent vs Descent Transfers

compute_full_transfer() distinguishes transfer direction for Hohmann:

  • Ascent (r_departure < r_target): departure at perigee of transfer orbit (ν=0), arrival at apoapsis (ν=π). burn_1_point = "perigee".
  • Descent (r_departure ≥ r_target): departure at apoapsis of transfer orbit (ν=π), arrival at perigee (ν=0). burn_1_point = "apoapsis".

This affects transfer_burn exit checks, transfer_coast arrival detection, and ETA computation.

transfer_coast State

Each tick:

  1. Mid-course SMA corrections, no OOP. Purely tangential (prograde/retrograde) burns maintain the planned transfer orbit SMA against N-body perturbations (e.g. Jupiter tidal forces at Callisto). When |a_ship - a_transfer| > 0.5% × a_transfer, compute vis-viva Δv correction and apply with per-tick cap. No cross-track thrust — OOP is handled by the plane_change phase before departure and after circularize.
  2. Pre-orient for circularization burn direction
  3. Detect arrival:
    • Hohmann ascent (burn_point = "perigee"): angle_near_pi(ν, window_half) — arrive at apoapsis
    • Hohmann descent (burn_point = "apoapsis"): angle_near_zero(ν, window_half) — arrive at periapsis
    • Radius-crossing fallback (#1066): For low-eccentricity transfer orbits (same-altitude phasing), ω drifts numerically and ν jumps unpredictably, so the anomaly-based check may never trigger. Fallback: if r_prev crosses transfer_target_r (descent: r_prev > target ≥ r_mag; ascent: r_prev < target ≤ r_mag), consider arrived. Only applies to Hohmann transfers.
    • High-energy: r_prev crossed transfer_target_r (ascending or descending). Same-altitude fallback: for same-altitude fast/express transfers, the transfer orbit periapsis equals the target radius. Finite burn imprecision can leave periapsis above target, so exact crossing may never be detected. Fallback: compute orbit periapsis r_pe = a × (1 - e) from orbital elements; if |r_pe - r_target| / r_target < 10% AND near periapsis (ν within ±30° of 0), consider arrived. The 10% tolerance handles the transfer_target_r discrepancy between computed orbital elements and actual position (~200km for LEO). For different-altitude transfers (e.g., 400km→35000km), periapsis-vs-target difference is ~84% — no false trigger.
  4. On arrival: recompute dv2 from current state (vis-viva for Hohmann, vector diff for high-energy), store transfer_dv_rem, transition to circularize
  5. Update ETA: time to target anomaly (apoapsis for ascent, periapsis for descent), store transfer_r_prev = current_radius
circularize State

Each tick:

All strategies use the same unified approach. The correction method depends on time scale:

Low time scale (dt_game ≤ CIRC_GAIN_DT_REF, i.e., ≤50s):

  1. Compute velocity target (two modes):
    • Vis-viva mode (default, when |a_ship - a_target| ≥ CIRC_CIRCULAR_BLEND_THRESHOLD × a_tol): v_target = sqrt(μ * (2/r - 1/a_target)). Targets the correct velocity for the target orbit at the current radius — converges the SMA efficiently.
    • Circular velocity mode (when |a_ship - a_target| < CIRC_CIRCULAR_BLEND_THRESHOLD × a_tol): v_target = sqrt(μ / a_target). Targets the circular velocity at the target orbit radius, regardless of current radius. This actively corrects eccentricity by burning prograde at apoapsis and retrograde at periapsis. Uses moderate gain (CIRC_CIRCULAR_GAIN = 0.5) to ensure convergence outpaces per-orbit velocity oscillation. CIRC_CIRCULAR_BLEND_THRESHOLD = 3.0 (3× the SMA tolerance).
  2. Tangential-only corrections with total-speed comparison (dv_r = 0, dv_t = v_target - v_mag, dv_rem = |dv_t|). Radial velocity is natural Keplerian motion — zeroing it wastes fuel and amplifies eccentricity. Total-speed comparison (same in both modes) avoids prograde bias from comparing against v_tangential and prevents mode-switching discontinuity at the vis-viva/circular boundary. In circular mode, this still circularizes correctly: retrograde at periapsis (v_mag > v_circ), prograde at apoapsis (v_mag < v_circ).
  3. Burn direction: pure prograde/retrograde (0, sign(dv_t), 0) in RTN. Thrust fraction scaled by dv_rem / dv_per_tick.
  4. Per-step dv cap: thrust_fraction = min(thrust_fraction, PHASE_MAX_DV_PER_TICK / dv_per_tick) when dv_per_tick > PHASE_MAX_DV_PER_TICK. Caps actual Δv per integration sub-step to 10 m/s regardless of time scale or ship acceleration. At 1× (frigate dv_per_tick=25 m/s): throttle ≤ 0.4. At 50× with sub_dt=10s (dv_per_tick=250 m/s): throttle ≤ 0.04. Without this cap, high-TWR ships at high time scales apply 15%+ of circular velocity per sub-step, causing Q-law to overshoot the target orbit and diverge.

High time scale (dt_game > CIRC_GAIN_DT_REF, i.e., >50s) — apse-targeted tangential burns:

At high time scales, per-tick corrections span many degrees of orbit. Continuous vis-viva velocity matching causes oscillation because the target velocity changes significantly between ticks, and radial velocity zeroing fights natural Keplerian dynamics. Instead, burns are restricted to apse windows where tangential corrections efficiently change the opposite apse:

  1. Determine orbital position using true anomaly ν from orbital elements.
  2. Near periapsis (|ν| < CIRC_APSE_HALF_WIDTH or |ν - 2π| < CIRC_APSE_HALF_WIDTH): Compute tangential Δv to correct apoapsis:
    • a_adj = (r_ship + r_ap_target) / 2 where r_ap_target = a_target × (1 + e_target)
    • v_needed = sqrt(μ × (2/r_ship - 1/a_adj))
    • dv_t = v_needed - v_tangential
  3. Near apoapsis (|ν - π| < CIRC_APSE_HALF_WIDTH): Compute tangential Δv to correct periapsis:
    • a_adj = (r_ship + r_pe_target) / 2 where r_pe_target = a_target × (1 - e_target)
    • v_needed = sqrt(μ × (2/r_ship - 1/a_adj))
    • dv_t = v_needed - v_tangential
  4. Between apsides: Coast (zero thrust), maintain prograde attitude. dv_t = 0.
  5. Angular momentum mode is disabled (CIRC_APSE_E_MIN = 0.0). Apse-targeted mode handles all eccentricities including e → 0. When the orbit is already converged (correct SMA and low e), the vis-viva formula gives dv_t ≈ 0 at all apsides — no fuel waste. Angular momentum mode was found to drain 20%+ fuel without converging eccentricity (symmetric corrections cannot reduce e effectively; only asymmetric apse burns can).
  6. dv_rem = sqrt(dv_t² + dv_r²). In apse-targeted mode, dv_r = 0 (tangential only). Burn direction: prograde/retrograde at apsides.
  7. Per-step dv cap with dynamic scaling (#725): When sma_ratio > CIRC_WIDE_WINDOW_THRESHOLD (3×), scaled_cap = PHASE_MAX_DV_PER_TICK × min(sma_ratio, CIRC_DV_CAP_MAX_SCALE). Otherwise scaled_cap = PHASE_MAX_DV_PER_TICK (base 10 m/s). When the ship’s SMA is far from the target (e.g., 10× for a 24,466 km orbit targeting 2,548 km), the cap scales up to 100 m/s per tick (10× the base 10 m/s). Near convergence (sma_ratio ≤ 3), the cap stays at 10 m/s to prevent overshooting. thrust_fraction = min(thrust_fraction, scaled_cap / dv_per_tick) when dv_per_tick > scaled_cap. Apse window widening was attempted but reverted — the vis-viva apse formula gives wildly incorrect Δv at positions far from the actual apse, causing orbit destruction for high-e orbits. Only the dv cap scales dynamically.

This converges eccentricity directly — each apse burn targets the specific orbit element that needs correction. At CIRC_APSE_HALF_WIDTH = 30°, each apse window spans ~17% of the orbit.

  1. Exit conditions (either triggers _circularize_complete):
    • ap_pe_converged with period-scaled tolerance: apoapsis and periapsis each within circ_exit_tol of target, where circ_exit_tol = a_tol × max(1, √(T_orbit / T_ref)). T_orbit is the ship’s orbital period, T_ref = PHASE_MAX_DEG_REF_PERIOD (~5500s, LEO reference). At LEO: factor ≈ 1 (no change from base a_tol = 100 km). At 35,000 km altitude (T ≈ 83,600s): factor ≈ 3.9, giving ~390 km tolerance. This is necessary because per-orbit convergence rate is ~50%/orbit regardless of altitude, but the orbital period (and thus wall-clock time per orbit) scales with altitude. Without period scaling, high-orbit circularize takes 4+ orbits (56+ minutes at 35,000 km) to reach the LEO-calibrated 100 km tolerance, exceeding test timeouts. Subsequent phases (phase matching, approach) handle residual orbit error through additional burns.
    • Stall detection (two conditions, either triggers exit). Uses minimum tracking over dynamic-length windows: tracks min(dv_rem) and min(orbit_err) (where orbit_err = max(ap_err, pe_err)) across each window and compares between windows to eliminate orbital oscillation artifacts. For phasing orbits (transfer_is_phasing == "true"), orbit_err is computed against the phasing orbit (circular at r_target_orbit), not the rendezvous target — the ship is intentionally at a different altitude, and using the rendezvous target gives a structural orbit_err of ~15,000 km that never improves, causing false stalls after 180 ticks. Window size = max(60, orbit_period_ticks) where orbit_period_ticks = ceil(2π√(a_target³/μ) / time_scale) using the target orbit SMA (not the ship’s current SMA, which changes each tick during convergence and can be degenerate/hyperbolic):
      • Circular-mode stall: Disabled (CIRC_APSE_E_MIN = 0.0). Angular momentum mode is no longer used. All stall detection uses the apse-targeted conditions below.
      • Oscillation stall: min(dv_rem) hasn’t improved by >10% compared to previous window’s minimum AND min(orbit_err) hasn’t improved by >5% either. Both conditions must hold — if the orbit shape is converging (ap/pe errors shrinking), circularize continues even if dv_rem oscillates. The orbit_err threshold is 5% (more lenient than dv_rem’s 10%) because linear gain scaling at high time scales produces slower convergence rates — orbit_err may improve only ~8% per window at 100× time scale, which is genuine convergence that should not trigger a stall. Tracks circularize_stall_orbit_err_baseline and circularize_stall_orbit_err_min alongside the dv_rem fields. Near-zero baseline: when both min(dv_rem) and baseline are < 1.0 m/s, the orbit has converged to the circularize target (possibly a phasing orbit, not the final target) — dv_stalled is forced True. This prevents infinite loops when circularizing to a phasing orbit that doesn’t match the final target ship (orbit_err vs target is always large, but dv_rem vs phasing target is ~0). dv_rem tracking skips coast: dv_rem is only tracked when burn_point != "coast" to prevent coast dv_rem=0 from poisoning the minimum. On window reset during coast, the minimum is initialized to a sentinel (1e9) so the first apse-burn tick sets the real minimum.
      • Wrong-orbit convergence: both min(dv_rem) and the previous minimum are < 5.0 m/s AND orbit_err < 5 × a_tol (guard against false-trigger during apse-targeted coasting where dv_rem=0 between apsides). The vis-viva circularization converges to a velocity near the target but the orbit shape (ap/pe) doesn’t match due to eccentricity or altitude offsets. The 5.0 m/s threshold catches cases where dv_rem bottoms out at 1-3 m/s.
      • Regression guard (continuous): If dv_rem > circ_stall_baseline × CIRC_STALL_REGRESSION_FACTOR (2.0) and baseline is established, declare stall immediately with type “regression”. Catches catastrophic divergence where a transient minimum masks that current conditions are far worse than baseline. Only checked when burn_point != "coast".
      • Both conditions are time-scale independent. The _circularize_complete handler routes based on stall type (see Phasing Loop routing below).
      • Stall field reset: When entering circularize from transfer_coast, all stall tracking fields (circularize_stall_baseline, circularize_stall_min, circularize_stall_orbit_err_baseline, circularize_stall_orbit_err_min, circularize_stall_ticks) are cleared. This ensures each circularize attempt evaluates convergence independently — stale baselines from prior attempts would cause false wrong_orbit stalls (e.g., baseline=0.4 from cycle 1 persisting into cycle 2 where the orbit is actually converging).
  2. Thrust distribution (apse-targeted mode only): The vis-viva formula recalculates dv_t from current orbital elements each tick. After one tick applies the correction, the orbit changes so that subsequent ticks see dv_t ≈ 0 — the formula is self-correcting. No window budget or remaining-tick tracking is needed.
    • window_fraction = 2 × CIRC_APSE_HALF_WIDTH / 360
    • T_orbit = 2π√(a_ship³/μ)
    • ticks_in_window = max(1, floor(T_orbit × window_fraction / dt_game))
    • If ticks_in_window < 3: conservative mode — budget only 50% of Δv per pass (dv_budget = dv_rem × CIRC_CONSERVATIVE_PASS_FRACTION). Prevents overcorrection when burn time is a large fraction of the orbital period and the ship passes through the apse window too quickly for multi-tick settling.
    • If ticks_in_window ≥ 3: full correction — no gain cap. The first few ticks at each apse correct the orbit, and remaining ticks see dv ≈ 0. Convergence in 1-2 apse passes (periapsis + apoapsis) regardless of orbital period.
    • (Disabled) Angular momentum mode was removed. Apse-targeted handles all eccentricities.
  3. ETA = dv_rem / accel
  4. Periapsis impact protection (after apse-targeted burn/coast decision): When the orbit has periapsis below the body surface (rp < body_radius), the apse-targeted coast/burn decision is overridden with an emergency burn to raise periapsis. Without this, a ship coasting between apse windows on an impact trajectory will crash before reaching the next apse burn window. (#720)

    Detection: rp = a × (1 - e) from current orbital elements. If rp < body_radius, the orbit intersects the surface.

    Emergency burn direction: Prograde tangential + radial outward (combined):

    • Tangential component: dv_t = v_circular - v_tangential (at minimum match circular velocity — prograde thrust near apoapsis raises periapsis)
    • Radial component: dv_r = |v_radial| when falling inward (v_r_signed < 0) — arrests inward velocity to prevent surface impact. When outbound (v_r_signed >= 0), dv_r = 0.
    • burn_point = "emergency"
    • Full throttle: thrust_fraction = 1.0

    Exit: Normal apse-targeted logic resumes once periapsis rises above body_radius. The emergency burn raises periapsis rapidly (TWR » 1 for most ships at moon-scale bodies), typically within a few ticks.

    Why not reuse brachistochrone periapsis protection: The brachistochrone guard blends outward-radial thrust into an existing ZEM/ZEV direction. Circularize has no ZEM/ZEV — it uses apse-targeted burns with explicit coast periods. The emergency override must replace the coast decision entirely, not blend into it.

This converges at any time scale. Apse-targeted mode uses tangential-only corrections at periapsis/apoapsis windows — each burn directly targets the opposite apse, providing the asymmetric corrections needed for eccentricity reduction (retrograde at periapsis lowers apoapsis, prograde at apoapsis raises periapsis). The self-correcting vis-viva formula ensures convergence in 1-2 apse passes for eccentric orbits. Conservative mode prevents overcorrection at low resolution. Period-scaled exit tolerance ensures timely convergence at all altitudes.

Phasing Loop

Circularize exit conditions:

  • ap_pe_converged(oe_ship, oe_target): ship orbit matches rendezvous target. Skipped for phasing orbits (transfer_is_phasing == "true") — the ship is intentionally at a different altitude than the rendezvous target. Without this guard, the check can pass immediately (before or during the transfer) because the ship’s orbit still matches the rendezvous target from the previous phase, causing the phasing orbit to never actually execute. This results in near-zero differential mean motion in phase_coast (the ship stays at the target orbit instead of the computed phasing orbit).
  • Near-converged fast-path: when standard ap_pe_converged fails but orbit_err < 2× circ_exit_a_tol (orbit within twice the period-scaled tolerance), treat as converged after CIRC_STALL_MIN_WINDOW (60) ticks. This handles high-orbit residual eccentricity where Q-law converges to dv_rem ≈ 0 but orbit error stays just above the standard tolerance. Without this, the stall detection window (proportional to orbital period) takes too long for high orbits — e.g., at 35,000 km the stall detection needs ~1400 real seconds, exceeding test timeouts. The 2× tolerance is safe because _circularize_complete routes to phase for fine corrections.
  • circ_stalled: stall detection triggered (oscillation or wrong-orbit)
  • Phasing orbit convergence: when transfer_is_phasing == "true", check |Δa| < a_tol against the phasing SMA (not the rendezvous target) AND e < 0.02. This allows phasing orbits to exit circularize quickly — the standard ap_pe_converged check would never pass because the ship targets a different altitude than the rendezvous target.

After circularize completes (either ap_pe_converged, stall detected, or phasing converged), routing is checked in _circularize_complete in this priority order:

  1. If circularize stalled (any stall type) AND transfer_is_phasing == "true" AND |Δφ| > 2°phase_coast (for phasing orbits, any stall means the orbit is close enough — at high time scales the vis-viva oscillation floor can be ~30 m/s but the SMA is within ~1% of target, close enough for phasing coast)
  2. If circularize stalled on a non-phasing orbit AND |Δφ| > 2°phase_coast with transfer_target_r set to the ship’s current SMA. The ship drifts on its current (imperfect) orbit until the phase gap closes, then the next transfer cycle corrects the orbit. Without this coast, the phase handler immediately re-enters transfer_plan, creating rapid cycling that never accumulates enough drift to close the gap (#1066). 2b. If circularize stalled on a non-phasing orbit AND |Δφ| ≤ 2°phase (gentle corrections; the phase handler will trigger a corrective transfer when the orbit doesn’t match)
  3. If transfer_is_phasing == "true" AND |Δφ| > 2°phase_coast
  4. If |Δφ| ≤ 2°phase
  5. If orbit converged (ap_pe_converged with exit tolerances) AND |Δφ| > 2°phase_coast with transfer_target_r set to the ship’s current SMA. Same reasoning as case 2: prevents the phase handler from immediately cycling to transfer_plan. Exit tolerance propagation: _circularize_complete accepts an exit_tolerances parameter (the period-scaled tolerances used by circularize exit). At this check, uses exit_tolerances if provided, otherwise falls back to standard tolerances. This prevents a mismatch where circularize exits using relaxed (period-scaled) tolerance but _circularize_complete checks with standard tolerance. 5b. If orbit converged AND |Δφ| ≤ 2°phase (gentle corrections to close the final gap).
  6. Otherwise → transfer_plan (re-enter for correction transfer)
phase_coast State

Each tick:

  1. Compute phase angle to target
  2. Sign mismatch (overshoot) detection: only for small phase angles (|Δφ| < 60°) and significant altitude offset (|r_phasing − a_target| > 0.1% a_target). The 0.1% tolerance prevents false sign mismatch from floating-point noise in OE computation for same-altitude rendezvous. Large phase angles (e.g. 179°) arise from phase drift during the transfer and need continued coasting — the ship will lap the target.
  3. Drift-compensated transition: Compute the Hohmann phase drift from the current orbit to the target orbit: drift = |π − n_target × T_hohmann/2| where T_hohmann/2 = π × √(a_transfer³/μ) and a_transfer = (a_ship + a_target)/2. The transition threshold is max(2°, drift + 0.5°). This ensures the cycle 2 Hohmann arrives with phase ≈ 0° — the phase drift during the half-orbit transfer exactly closes the remaining phase angle.
    • If |Δφ| ≤ threshold OR sign mismatch detected OR diverging:
      • Sign mismatch: route to phase (the Hohmann drift would make things worse for overshot cases)
      • Normal closure: transfer_plan (cycle 2) — the Hohmann brings the ship to the target orbit while the transit drift closes the remaining phase angle
  4. Divergence detection: Compute signed drift rate dphi_dt_signed = n_ship - n_target. Since d(phase)/dt = -(n_ship - n_target) = -dphi_dt_signed (phase angle decreases when ship orbits faster), the coast is diverging when phase_angle × dphi_dt_signed < 0 — the drift is increasing the phase angle magnitude instead of closing it. This happens when the ship circularized at the wrong altitude for its phase sign — e.g., ship behind target (phase > 0) but in a higher orbit (slower, dphi_dt < 0, falling further behind). On divergence, transition to transfer_plan to re-plan the transfer. (#1066: previously the sign was reversed, ejecting convergent coasts and causing the phasing limit cycle.)
  5. Otherwise: zero thrust, pre-orient prograde
  6. Compute ETA: remaining_angle / |n_ship - n_target|
  7. Coast safety valve (dynamic limit): compute n_target_orbits_needed from actual differential mean motion (not a fixed constant). Max coast = max(3, n_needed × 1.5) target orbits. This accommodates min_a clamping which reduces closure rate below the designed 30°/orbit. When recomputing, use altitude-scaled max_deg (n_orbits = ceil(|Δφ°| / max_deg), where max_deg = min(120, 30 × max(1, T_target/5500))) to match the phase handler. At LEO this gives 30°/orbit; at high orbits up to 120°/orbit, preventing excessive n_orbits that would produce unnecessarily close phasing orbits.
OOP Corrections During Transfer

Throughout transfer_coast and circularize (coast periods):

  • Apply oop_coast_steering() when i/Ω exceed tolerance and effectivity ≥ 0.15
  • OOP thrust is in H direction (orbit normal), orthogonal to in-plane GVE rows → no interference
Backward Compatibility

Old phase names map on entry:

  • adjust_orbittransfer_plan
  • Old transfer_phase sub-states → transfer_plan (restart transfer)
  • Old fields (hohmann_departure_computed, hohmann_wait_departure, hohmann_depart_phase, phasing_target_a, transfer_phase, transfer_arrival_dv_t, transfer_arrival_dv_r, transfer_depart_phase) are cleared and replaced with new fields
Maneuver Hash Fields
Field Type Description
transfer_target_r string (meters) Target radius for this transfer leg
transfer_dv1 string (m/s) Planned Δv for burn 1
transfer_dv2 string (m/s) Planned Δv for burn 2
transfer_dv_rem string (m/s) Remaining Δv for current burn
transfer_a string (meters) SMA of transfer ellipse
transfer_time string (seconds) Estimated coast time
transfer_is_phasing "true"/"false" Whether targeting phasing orbit
transfer_burn_point "perigee"/"apoapsis" Where burn 1 should occur (derived from transfer_dv1 sign if missing)
departure_phase_target string (radians) Ideal departure phase angle
transfer_r_prev string (meters) Previous radius (for crossing detection)
transfer_cycle "1"/"2" Which transfer cycle (phasing=1, real=2)
transfer_nu_arrival string (radians) Expected true anomaly at arrival
transfer_v_arrival string (m/s) Expected speed at arrival
transfer_gamma string (radians) Flight path angle at arrival
departure_immediate "true"/"false" Skip departure_wait active burns, depart immediately. Set for cycle 2 returns and phase correction transfers.
phase_correction_count string (int) Number of phase → transfer_plan corrective transfers completed (max PHASE_CORRECTION_MAX = 3)
phase_active_count string (int) Number of computed phasing orbit cycles used (max PHASE_ACTIVE_MAX_CYCLES = 3)
Status Text Format
State Format
departure_wait "[Strategy] waiting for departure, Δφ=X°, ETA Yh Zm" (passive) or "[Strategy] active departure wait (pro/retrograde), Δφ=X°, ETA Yh Zm" (active)
transfer_burn "[Strategy] burn 1 [coast→pe\|burning], Δv_rem=Xm/s, ETA Ym"
transfer_coast "[Strategy] coast→target, alt=Xkm, ETA Ym"
circularize "[Strategy] circularize, dv_rem Xm/s" (near apoapsis) or "[Strategy] coast→apo, dv_rem Xm/s, ETA Ym" (coasting)
phase_coast "Phasing coast Δφ=X°, ETA Yh"
ETA Computation
State ETA =
departure_wait remaining_phase / |phase_rate|
transfer_burn time_to_perigee + burn_time (Hohmann); burn_time (high-energy)
transfer_coast time_to_arrival + circularize_burn_time
circularize time_to_apoapsis + burn_time (Hohmann coast); burn_time (near apoapsis or high-energy)
phase_coast remaining_phase / |Δn| + transfer_2_estimate
Constants
Constant Value Description
HOHMANN_BURN_WINDOW_MARGIN_DEG 10.0 Degrees margin beyond computed burn arc
HOHMANN_MIN_WINDOW_DEG 15.0 Minimum burn window half-width (degrees)
HOHMANN_MAX_WINDOW_DEG 90.0 Maximum burn window half-width (degrees)
HOHMANN_OOP_EFF_GATE 0.15 Minimum OOP effectivity during coast
HOHMANN_CIRCULAR_E_THRESHOLD 0.01 Below this eccentricity, burn immediately (skip angle check)
PHASING_HOHMANN_ANGLE_THRESHOLD_DEG 2.0 Below this phase angle, target exact orbit
PHASING_HOHMANN_N_ORBITS 1.0 Close phase in N target orbits
PHASING_RECOMPUTE_RATIO 2.0 Recompute SMA when |Δφ| > ratio × designed closure
PHASING_COAST_MAX_ORBITS 3.0 Recompute SMA when coast ETA > this × target period
DEPARTURE_ACTIVE_WAIT_ORBITS 3 Switch to active phasing in departure_wait when ETA > this × target orbital period
DEPARTURE_ACTIVE_WAIT_MAX_S 7200 Hard cap (game seconds): switch to active phasing if ETA > this, regardless of orbital period. Prevents long passive waits on high orbits where 3 periods exceeds the cap.
DEPARTURE_HEADROOM_MIN_FRACTION 0.05 Minimum SMA headroom as fraction of target orbit radius. Ensures departure_wait active phasing creates sufficient SMA offset at high altitudes where a_tol × 5 is too small relative to orbit size. At GEO (42,164 km): max(500 km, 2,108 km) = 2,108 km. At LEO (7,000 km): max(500 km, 350 km) = 500 km (unchanged).
CIRCULARIZE_GAIN 0.3 (Unused) Was proportional gain for circular fallback mode. Circular mode is disabled.
CIRC_GAIN_DT_REF 50.0 (Unused) Was reference dt for circular fallback gain normalization.
CIRC_CONSERVATIVE_PASS_FRACTION 0.5 When ticks_in_window < 3, budget only 50% of Δv per apse pass to prevent overcorrection
CIRC_APSE_HALF_WIDTH 30.0° Half-width of apse windows for apse-targeted circularize. Burns only within ±30° of periapsis/apoapsis. Passed in degrees to angle_near_zero/angle_near_pi
CIRC_APSE_MAX_HALF_WIDTH 90.0° (Unused) Dynamic apse window widening was reverted — vis-viva formula gives incorrect Δv far from apsides.
CIRC_WIDE_WINDOW_THRESHOLD 3.0 Threshold for dynamic dv cap scaling: scale when ship SMA > this × target radius. (#725)
CIRC_DV_CAP_MAX_SCALE 10.0 Maximum multiplier for per-step dv cap during circularize. At sma_ratio = 10, cap = 100 m/s (10 × base 10 m/s). Prevents slow convergence for extreme orbit mismatches (e.g., post-SOI-capture e=0.92). (#725)
CIRC_APSE_E_MIN 0.0 Disabled. Apse-targeted mode handles all eccentricities. Angular momentum mode (circular fallback) drained 20%+ fuel without converging eccentricity — symmetric corrections cannot reduce e. At e → 0, apse-targeted vis-viva self-corrects: dv ≈ 0 when orbit is converged, no fuel waste.

PHASE Phase (Close Phase Angle)

Phasing uses gentle prograde/retrograde burns to create an SMA offset that causes differential mean motion, closing the phase angle to the target. Key parameters:

  • Per-step dv cap: dv_budget = PHASE_MAX_DV_PER_TICK × v_scale where v_scale = min(1, √(μ/a_target) / PHASE_DV_CAP_V_CIRC_REF). The base cap is 10 m/s, calibrated for LEO (v_circ ≈ 7670 m/s, so 10 m/s = 0.13% of orbital velocity). At bodies with lower orbital velocity (e.g. Callisto v_circ ≈ 1691 m/s), the cap scales down to maintain the same fractional perturbation — without this, the 10 m/s cap is 0.6% of v_circ, causing eccentricity to diverge within ~17 ticks (#1066). PHASE_DV_CAP_V_CIRC_REF = 7670 m/s (LEO 400 km reference). Caps actual Δv per integration sub-step independent of time scale. Same cap applies to departure_wait active phasing throttle and the circularize per-step cap.
  • Dynamic SMA headroom: Phasing throttle is limited by an SMA headroom factor that scales linearly with phase angle:
    • phase_headroom_factor = clamp(1.0 + (PHASE_SMA_HEADROOM_FACTOR - 1.0) × |phase_angle| / PHASE_HEADROOM_SCALE_ANGLE, 1.0, PHASE_SMA_HEADROOM_FACTOR)
    • At 30°+ Δφ: 5× a_tol headroom (unchanged). At 3° Δφ: ~1.4×. At 0°: 1×.
    • Orbit-relative floor: phase_headroom = max(a_tol × phase_headroom_factor, r_target × DEPARTURE_HEADROOM_MIN_FRACTION). At high altitudes (GEO+), the a_tol-based headroom is too small relative to orbit radius (1.2% at GEO), causing very slow phase drift. The orbit-relative floor (5% of target radius) ensures sufficient drift rate at all altitudes.
    • Effect: ship orbit stays close to target orbit as phase angle closes, reducing transfer work on re-entry. Eliminates phase↔transfer cycling.
  • Eccentricity catastrophic fallback: Triggers at PHASE_ECCENTRICITY_FALLBACK_FACTOR (10×) × e_tol instead of 5×. Relaxed because dynamic headroom reduces phasing duration, limiting eccentricity growth.
  • RAAN equatorial guard: RAAN (Ω) is undefined for equatorial orbits — at i ≈ 0°, numerical noise can produce RAAN errors of ~180° that represent negligible actual plane separation. All RAAN error checks (convergence, element_errors “ok”, catastrophic OOP fallback) weight the raw RAAN error by sin(max(i_ship, i_target)). At i = 0.1°, a 177° raw RAAN error becomes 0.31° effective — well within the 2° tolerance. This prevents spurious plane_change fallback from destroying near-equatorial orbits.
  • SOI guard: Retrograde thrust if apoapsis approaches Hill sphere boundary.
  • Corrective orbit transfer: When the phase angle is small but the orbit doesn’t match (not ap_pe_converged), the phase mode transitions to transfer_plan for a corrective Hohmann transfer with departure_immediate = "true". Drift-compensated threshold: the corrective transfer uses the same drift-compensated logic as phase_coast — it computes the Hohmann phase drift |π − n_target × T_hohmann/2| and only fires when |Δφ| ≤ max(2°, drift + 0.5°). This prevents the corrective Hohmann from introducing more phase drift than the remaining phase angle, which causes cycling (corrective Hohmann → phase drift overshoot → rephasing → corrective Hohmann → …). Favorable drift guard: additionally, the corrective transfer is skipped when the current SMA offset is actively closing the phase angle (favorable drift: phase_angle × (a_ship - a_target) < 0). In this case, the differential mean motion is already closing the gap, and a corrective Hohmann would disrupt the natural convergence. Guarded by a phase_correction_count counter (max PHASE_CORRECTION_MAX = 3) to prevent infinite loops. The departure_immediate flag prevents departure_wait active burns from changing the orbit further. Period-scaled tolerance: the ap_pe_converged check uses period-scaled tolerance (same formula as circularize exit: a_tol × max(1, √(T_orbit / T_ref))), not standard tolerance.
  • Computed phasing orbit: When passive phasing ETA exceeds PHASE_ACTIVE_THRESHOLD_ORBITS × T_target (orbit-scaled, no game-time cap), or when the ship is at the same altitude as the target ( Δa ≤ 1m, meaning zero differential mean motion and infinite manual-phasing ETA), the phase mode switches to a computed phasing orbit to accelerate phase closure. The threshold scales purely with orbital period — 2 orbits at LEO (~11,000 game seconds) or at GEO (~170,000 game seconds). A fixed game-time cap would be too aggressive at high orbits (1,800s = 0.02 orbits at 35,000 km), triggering computed phasing for tiny phase angles. ETA estimation uses expected drift rate: the drift rate is computed using max(current_SMA_offset, phase_headroom), not the current offset alone. Right after circularize, the SMA offset is near-zero, giving enormous ETA even for small phase angles (~7° at 35,000 km). Using phase_headroom (the SMA offset that manual phasing will build through pro/retro burns) gives a realistic estimate of how long manual phasing will actually take. Computes the phasing orbit SMA via compute_phasing_orbit_sma() and re-enters transfer_plan with transfer_is_phasing = "true". Transfer-time compensation (#1066): when mu is provided, compute_phasing_orbit_sma iteratively accounts for the phase shift during the return Hohmann transfer. During the half-ellipse from phasing orbit to target orbit, the target advances n_target × T_hohmann radians while the ship sweeps π radians. The net offset (n_target × T_hohmann − π) is added to the required coast phase closure, and the SMA is recomputed until convergence (≤3 iterations). This prevents the limit cycle where phasing coast closes the gap but the return transfer re-opens it by the same amount (e.g., ~77° at Callisto). Without mu, the function falls back to the coast-only formula (backward compatible). The max phase per orbit scales with altitude: max_deg = min(PHASE_MAX_DEG_LIMIT, MAX_PHASE_PER_ORBIT_DEG × max(1, T_target / PHASE_MAX_DEG_REF_PERIOD)). At LEO (T≈5,500s) this gives 30°/orbit; at GEO/high orbits (T≈84,000s) this scales to 120°/orbit. This prevents multi-orbit phasing at high altitudes from exceeding practical timeouts (e.g., 4 orbits at 35,000 km altitude = 50+ minutes at 100×). This reuses the entire transfer state machine (departure_wait → transfer_burn → transfer_coast → circularize → phase_coast) instead of adding a new sub-state machine. Guarded by phase_active_count (max PHASE_ACTIVE_MAX_CYCLES = 3) to prevent infinite phasing loops. The same-altitude case prevents routing loops where a degenerate Hohmann transfer (dv≈0) repeatedly circularizes to the same orbit without closing the phase angle.
Constant Value Description
PHASE_HEADROOM_SCALE_ANGLE 30° Full headroom at this phase angle or above
PHASE_SMA_HEADROOM_FACTOR 5.0 Maximum SMA headroom (×a_tol)
PHASE_ECCENTRICITY_FALLBACK_FACTOR 10.0 Fallback to transfer_plan when e_err > this × e_tol
PHASE_ACTIVE_THRESHOLD_ORBITS 2.0 Switch to computed phasing if passive ETA > this × target orbital period (no game-time cap)
MAX_PHASE_PER_ORBIT_DEG 30.0 Base max phase angle to close per orbit at LEO (limits SMA offset for stability)
PHASE_MAX_DEG_LIMIT 120.0 Absolute max phase per orbit regardless of altitude
PHASE_MAX_DEG_REF_PERIOD 5500.0 LEO reference orbital period (seconds) for scaling max_deg
PHASE_ACTIVE_MAX_CYCLES 3 Max computed phasing cycles before passive fallback

APPROACH Phase (Dual-Mode: Closing + Braking)

Time-scale-independent dual-mode approach. All control uses the alignment gate (ALIGNMENT_GATE_DEG = 5°) — zero thrust when misaligned, full thrust when aligned. Physics substep integration ensures continuous force application regardless of tick rate.

Both modes use body-frame forward RCS ([0, 0, 1], +Z = ship nose). Since the attitude controller steers body-forward toward the desired direction (target or retrograde), commanding body-forward naturally tracks the correct ICRF direction through substep integration. No ICRF→body conversion needed — eliminates stale body-frame commands when the ship rotates mid-tick.

Combined Δv budget: Both modes use a combined main+RCS Δv budget for proportional throttling: combined_dv_per_tick = dv_per_tick + rcs_dv_per_tick. A single brake_fraction = min(1.0, desired_dv / combined_dv_per_tick) is applied to both main engine and RCS. This prevents over-braking — without this, each thruster independently tries to cancel the full velocity, causing 2× Δv and oscillation (especially at high time scales where each tick spans many game-seconds).

Closing mode (rel_vel < CLOSING_VEL_THRESHOLD AND distance > 100 m):

  1. Compute closing velocity (projection of relative velocity toward target)
  2. Deceleration profile: target_closing = √(2 × a × d × safety), clamped to [0.5, MAX_CLOSING_VEL]
  3. Set attitude mode TARGET (point toward target)
  4. If closing_vel < target × 0.8: fire engines proportionally using combined budget (gated by alignment)
  5. brake_fraction = min(1.0, vel_deficit / combined_dv_per_tick) if aligned within gate, else 0
  6. Main engine: brake_fraction, RCS body-forward: brake_fraction
  7. If closing_vel ≥ target: coast (thrust = 0, RCS = 0)

Braking mode (rel_vel ≥ CLOSING_VEL_THRESHOLD OR distance ≤ 100 m):

  1. Set attitude mode TARGET_RETROGRADE (point opposite relative velocity)
  2. brake_fraction = min(1.0, rel_vel / combined_dv_per_tick) if aligned within gate, else 0
  3. Main engine: brake_fraction, RCS body-forward: brake_fraction

Why body-forward RCS works: The attitude controller settles in ~8 seconds regardless of time scale. Body-forward ([0, 0, 1], +Z = ship nose) is always correct once aligned. The alignment gate ensures zero thrust during reorientation (angle > 5°), preventing off-axis burns. Once aligned, full thrust is applied along the correct direction. The physics engine transforms body-frame to ICRF each substep using the current attitude, so the effective ICRF thrust direction tracks the ship’s rotation continuously.

RCS thrust per axis (from ship class config): Cargo Hauler 10 kN, Fast Frigate 4 kN, Long-Range Explorer 6 kN. RCS acceleration is small relative to main engines but sufficient for fine maneuvering at < 2 m/s relative velocity.

Completion: distance < 100 m AND relative velocity < 1 m/s. On completion, RCS translation is zeroed.

Constant Value Description
APPROACH_DECEL_BUDGET 0.1 m/s² Deceleration budget for closing profile
MAX_CLOSING_VEL 50.0 m/s Cap on closing velocity
CLOSING_VEL_THRESHOLD 20.0 m/s Switch to closing mode when Δv below this
APPROACH_STOPPING_SAFETY 0.5 Fraction of distance reserved as stopping margin

Brachistochrone Strategy (Direct Continuous-Thrust)

Brachistochrone skips the orbit-matching pipeline entirely. The ship flies a direct trajectory to the target using ZEM/ZEV (Zero-Effort-Miss / Zero-Effort-Velocity) feedback guidance — the standard closed-loop guidance law for powered rendezvous.

Phase Flow
_start_maneuver → transfer_plan (brachistochrone branch) → brachistochrone → approach → complete

For cross-body transfers where the ship cannot decelerate within the target body’s SOI (low-TWR ships):

_start_maneuver → transfer_plan → brachistochrone → capture → circularize → phase → approach → complete

No plane_change, no departure_wait, no transfer_burn/coast. The transfer_plan phase computes fuel/time estimate and initializes brach_tgo, then immediately transitions to brachistochrone. The brachistochrone phase runs ZEM/ZEV guidance each tick. For same-body or high-TWR cross-body transfers, transitions directly to approach when within approach distance and velocity. For low-TWR cross-body transfers, transitions to capture upon SOI entry (see Capture-First Arrival).

Guidance Law: ZEM/ZEV Feedback Guidance (E-Guidance)

Each tick, the guidance law computes a commanded acceleration vector:

a_cmd = 6 × ZEM / tgo² + 2 × ZEV / tgo

Where:

  • ZEM (Zero-Effort-Miss) = position error if both ships coast from now to tgo under gravity only (Kepler propagation)
  • ZEV (Zero-Effort-Velocity) = velocity error if both ships coast from now to tgo under gravity only
  • tgo = time-to-go (estimated remaining transfer time, decremented each tick)

Algorithm each tick:

  1. Compute achievable flight-time estimate: t_est = brachistochrone_time_estimate(distance, accel)
  2. Decrement tgo by dt_game, floor at max(t_est, 1.0) (adaptive floor prevents oscillation)
  3. Kepler-propagate ship and target positions/velocities forward by tgo (gravity only, no thrust)
  4. Compute ZEM = target_coast_pos - ship_coast_pos and ZEV = target_coast_vel - ship_coast_vel
  5. Compute a_cmd = 6 × ZEM / tgo² + 2 × ZEV / tgo
  6. Set thrust direction to normalize(a_cmd) via ATTITUDE_DIRECTION mode
  7. Set throttle to clamp(|a_cmd| / max_accel, 0, 1)

Key properties:

  • No explicit flip logic — ZEM/ZEV naturally reverses thrust direction as the ship approaches intercept
  • Handles gravity correctly via Kepler propagation (no Hohmann paradox)
  • Converges to matched position AND velocity at tgo → 0
  • Throttle varies continuously between 0 and 1
ATTITUDE_DIRECTION Mode

ZEM/ZEV computes an arbitrary thrust direction each tick. A new attitude mode ATTITUDE_DIRECTION accepts a Vec3 direction and holds it until updated. Added to the physics proto as enum value 14 with an optional direction field on SetAttitudeModeRequest.

Kepler Propagation

ZEM/ZEV requires predicting where ship and target will be at t + tgo under gravity only. Uses a universal-variable Kepler propagator (Curtis Algorithm 3.4) with existing Stumpff functions (_stumpff_c2, _stumpff_c3).

tgo Management
  • Initialized to brachistochrone_time_estimate(distance, accel) × 1.5 (50% margin)
  • Decremented by dt_game each tick
  • Adaptive floor: tgo never drops below 0.5 × brachistochrone_time_estimate(distance, accel) — this prevents tgo from falling far below the achievable flight time, which would cause enormous clamped acceleration commands, overshoots, and oscillation. The 0.5 factor balances stability (higher floor) vs convergence speed (lower floor)
  • Absolute minimum: 1.0 second (prevents divide-by-zero)
Approach Handoff

When distance < BRACH_APPROACH_DIST, transition to existing approach phase if either:

  • rel_vel < BRACH_APPROACH_VEL (velocity low enough), OR
  • stopping_dist < distance × 0.8 where stopping_dist = rel_vel² / (2 × max_accel) (ship can stop within 80% of remaining distance)

The stopping-distance check allows high-thrust ships arriving fast (e.g., 200 m/s at 1 km) to hand off early — the approach phase handles any velocity correctly with retrograde braking and proportional throttle.

Stall Detection

When brach_elapsed > brach_total_time × 2 and distance < BRACH_APPROACH_DIST × 10, the brachistochrone has been oscillating near the target far longer than planned without reaching the normal approach handoff distance. Force transition to approach phase — its retrograde braking mode will kill the oscillating velocity, then closing mode covers the remaining distance.

Fuel Depletion

Checked BEFORE the normal approach handoff because with no fuel, the approach phase cannot decelerate (it requires rel_vel < 1.0 m/s for completion, stricter than the dock tolerance).

When fuel < 1.0 kg, three-tier fallback:

  1. Dock: If distance < 500 m AND rel_vel < 10 m/s AND dock_on_arrival == true → force-complete maneuver (triggers auto-dock via _complete_maneuver)
  2. Approach: If distance < BRACH_APPROACH_DIST (1 km) → transition to approach phase (approach can complete/dock if velocity is low enough)
  3. Coast: Otherwise, thrust set to 0, attitude set to TARGET_RETROGRADE — normal approach handoff still possible if relative velocity decays
Constants
Constant Value Description
BRACH_APPROACH_DIST 1,000 m Hand off to approach phase (1 km)
BRACH_APPROACH_VEL 50.0 m/s Max velocity for approach handoff
Maneuver Hash Fields
Field Type Description
brach_t_estimate string (seconds) Estimated transfer time
brach_tgo string (seconds) Current time-to-go
Status Text Format
State Format
guidance "Brachistochrone (Xkm, tgo Ys)"
approach "Brachistochrone — approach"
no fuel "Brachistochrone — no fuel (Xkm, Ym/s)"
periapsis safety "Brachistochrone (Xkm, tgo Ys) [periapsis safety]"
Periapsis Protection

Low-TWR ships (e.g., Cargo Hauler at TWR ~0.28) can sink into a planet during brachistochrone rendezvous. When the ZEM/ZEV commanded acceleration exceeds the ship’s maximum (throttle saturates at 1.0), the orbit can decay because the ship can’t overcome gravity while following the guidance direction.

After computing the ZEM/ZEV a_cmd direction, the guidance checks the ship’s current orbital periapsis relative to the reference body. If periapsis falls below the safe altitude, the command direction is smoothly blended toward radial-outward.

Parameters (from existing compute_periapsis_barrier_params()):

  • min_rp: body radius + safe orbit altitude (e.g., Earth surface + 200 km)
  • rp_safe: min_rp + margin (scaled by body radius)

Blend behavior:

Condition Behavior
rp >= rp_safe Pure ZEM/ZEV guidance (no modification)
rp_safe > rp > min_rp Remove inward-radial component from a_cmd direction; add outward-radial component proportional to alpha = (rp_safe - rp) / (rp_safe - min_rp); force throttle >= alpha
rp <= min_rp Full radial-outward thrust at maximum throttle (survival mode)

Near-target exception: When distance < 5000 m (5 km from the rendezvous target), periapsis protection is skipped entirely. The target is in a stable orbit — flying near it is inherently safe. Without this exception, braking near the station temporarily lowers the computed periapsis (less orbital velocity → lower periapsis), triggering radial-outward safety thrust that pushes the ship away from the station, causing oscillation.

Outbound-past-periapsis exception: On hyperbolic or high-eccentricity orbits where the ship is outbound (radial velocity > 0) and above periapsis (r > rp), the computed periapsis is in the past — the ship will never revisit it. In this case, periapsis protection is skipped entirely. This prevents the safety override from fighting ZEM/ZEV deceleration during brachistochrone rendezvous on escape trajectories.

Stopping-distance exception: On unbound (hyperbolic) orbits only, when a ship is moving inward (radial velocity < 0), the guard computes the radial stopping distance: d_stop = v_radial² / (2 × max_accel). If d_stop < 0.5 × (r - min_rp), the guard is skipped — the ship can decelerate before reaching the danger zone. This only applies to unbound orbits because bound (elliptic) orbits will always reach periapsis regardless of current radial velocity; gravity accelerates the ship as it falls inward. Example: a Fast Frigate on a hyperbolic approach to Luna at 33 km/s from 55,000 km needs ~15,000 km to stop (vs 26,000 km threshold) — guard skipped. A ship in a bound Mars orbit with low current radial velocity — guard remains active (bound orbit, will reach periapsis).

Rendezvous-completes-first exception: On bound (elliptic) orbits, if the time to periapsis exceeds the brachistochrone time-to-go (tgo), the rendezvous maneuver will alter the orbit before periapsis is reached — the projected impact will never occur. In this case, periapsis protection is skipped. This prevents the guard from wasting Δv on survival thrust against a trajectory the ship will never fly, which is especially costly when chaser and target are in different orbital planes (e.g., retrograde vs prograde), where the guard diverts thrust away from the velocity reversal needed to match the target. Computed via Kepler’s equation: t_periapsis = time_to_anomaly(nu_current, 0, a, e, mu). A 20% safety margin is applied: protection is only skipped when tgo × 1.2 < t_periapsis.

Algorithm:

  1. Compute periapsis rp from ship’s body-relative state vector
  2. Compute min_rp and rp_safe from reference body parameters
  3. If rp < rp_safe: a. Compute radial velocity v_r = dot(v_rel, r_hat) and current radius r b. If rp < r and v_r > 0: skip protection (periapsis is in the past) c. If v_r < 0 and orbit is unbound (specific energy > 0): compute d_stop = v_r² / (2 × max_accel). If d_stop < 0.5 × (r - min_rp): skip protection (ship can decelerate in time) c2. If orbit is bound (specific energy < 0) and tgo > 0: compute t_periapsis via Kepler’s equation. If tgo × 1.2 < t_periapsis: skip protection (rendezvous completes before periapsis) d. Compute blend factor alpha = clamp((rp_safe - rp) / (rp_safe - min_rp), 0, 1) e. Compute radial-outward unit vector r_hat from body center to ship f. Remove any inward-radial component from guidance direction (project out negative radial) g. Add alpha × r_hat to guidance direction h. Renormalize direction i. Force throttle >= alpha
  4. Status text appends " [periapsis safety]" when active
Body Obstruction Avoidance

When the ZEM/ZEV guidance direction passes through a celestial body (e.g., the departure body is between the ship and the target), the burn direction must be deflected to avoid impact. This applies to:

  • Interplanetary phase: The departure body (first element of SOI chain) may obstruct the path to the target body when the ship escapes on the far side.
  • Brachistochrone phase: On cross-body transfers, the reference body may obstruct the path to the target.

Algorithm (deflect_around_body()):

  1. Compute clearance radius: r_clear = body_radius + safe_orbit_altitude (from SAFE_ORBIT_ALTITUDES)
  2. Compute ray-sphere closest approach: d_closest = |ship_rel × dir| (cross product magnitude; dir is the unit burn direction, ship_rel is ship position relative to body center)
  3. Check body is ahead: dot(dir, -ship_rel_hat) > 0 (direction has a component toward the body)
  4. If d_closest < r_clear AND body is ahead: a. Remove the inward-radial component from the direction (project out the component toward the body) b. If the remaining perpendicular magnitude is too small (< 0.1), use the prograde fallback (ship_vrel normalized) or orbit-normal (ship_rel × ship_vrel) as a tiebreaker c. Renormalize the direction vector d. Force throttle >= 0.5 to actively steer clear
  5. Otherwise, return the original direction unchanged

Interplanetary phase: Applied after ZEM/ZEV direction normalization. Uses the departure body (chain[0]) position/velocity from body_positions. Body-relative vectors computed from ctx.ship_pos/vel minus departure body pos/vel.

Brachistochrone phase: Applied after the prograde-escape block and before periapsis protection. Only active when cross_body=True and not prograde_escape. Uses the existing ship_rel and ship_vrel body-relative vectors.

Status text: Appends " [obstruction avoidance]" when active.

Bang-Coast-Bang Mode

For long-range transfers where pure brachistochrone requires more Δv than the ship has available, a bang-coast-bang profile splits the transfer into three sub-phases: burn1 (accelerate), coast (engine off), burn2 (brake). This reduces total Δv at the cost of longer transfer time.

Coast ratio c (0–1): fraction of distance spent coasting. c=0 = pure bang-bang (burn1 → burn2 with no coast phase).

Auto-compute: When coast_ratio is "auto" (default), the tick-engine computes the minimum coast ratio from the ship’s Δv budget (with 10% safety reserve):

  1. Compute Δv available via Tsiolkovsky: Δv_avail = Isp × g₀ × ln(m_total / m_dry)
  2. Compute pure brachistochrone Δv: Δv_brach = 2 × √(a × d)
  3. If Δv_brach ≤ Δv_avail × 0.9: use c = 0 (pure brachistochrone)
  4. Otherwise: k = (Δv_avail × 0.9 / 2)² / (a × d), then c = (1 - k) / (1 + k), clamped to [0, 0.9999]

Manual override: Player can set an explicit coast ratio (0–0.9999) via the rule editor UI.

Fuel budget mode: When max_fuel_fraction is set (0.0–1.0 exclusive/inclusive), the BCB planner limits fuel usage to max_fuel_fraction × fuel_capacity, capped at current fuel level. This allows players to conserve fuel at the cost of longer transfer time (higher coast ratio). Mutually exclusive with explicit coast_ratio — if both are specified, max_fuel_fraction takes precedence. If the fuel budget is too small for the transfer (planner returns None), the maneuver is rejected immediately with an infeasibility status message and aborted.

Max transfer time mode: When max_transfer_time is set (positive number, seconds), a binary search solver finds the minimum fuel needed to complete the BCB transfer within the time budget. The solver exploits monotonicity: more fuel → shorter total time. It binary-searches on fuel amount (0 to available fuel) for 60 iterations to find the minimum where total_time ≤ max_time. m0 stays constant (ship carries all fuel; budget just limits burns). If the time budget cannot be met even with all available fuel, the maneuver is rejected as infeasible. When both max_fuel_fraction and max_transfer_time are set, the solver computes fuel from each constraint independently and uses the lower (more restrictive) value.

Efficient budget mode: When budget_mode is "efficient", the planner automatically computes the Hohmann transfer time and uses it as max_transfer_time, so the minimum-fuel solver finds the least fuel needed for a transfer that takes no longer than a Hohmann transfer would. This gives the most fuel-efficient brachistochrone transfer. The computation happens at maneuver execution time (not rule creation time) so it reflects current orbital geometry. For same-SOI transfers, uses local semi-major axes: hohmann_transfer_time(a_ship, a_target, mu_local). For cross-SOI transfers (ship and target in different SOIs), finds the common parent body via find_common_parent(), computes each object’s distance from the common parent using absolute ICRF positions, and uses the common parent’s mu: hohmann_transfer_time(r_ship_to_parent, r_target_to_parent, mu_parent). Example: Luna → Earth transfer uses Earth as common parent with r1 ≈ 384,400 km → Hohmann time ≈ 5 days. Falls back to no max_transfer_time (full fuel) if body positions or parent mass are unavailable.

Fastest budget mode: When budget_mode is "fastest", the trajectory planner optimizes for minimum transfer time instead of minimum Δv. The departure window scan uses aggressive TOF multipliers [0.3, 0.4, 0.5, 0.6, 0.7] × Hohmann TOF and sorts candidates by TOF (shortest first). For cross-SOI transfers, "fastest" uses all available fuel (Lambert Δv serves as a floor to ensure enough for escape and capture, but not a cap). For same-SOI transfers, "fastest" uses all available fuel with no max_transfer_time constraint, giving the brachistochrone planner maximum burn capability for minimum transfer time.

Lambert-based fuel budget (cross-SOI): For cross-SOI brachistochrone transfers (ship and target in different SOIs), the fuel budget is derived from the Lambert-computed escape_dv + capture_dv via _compute_cross_soi_plan(), which is called before the BCB planner so that the Δv data is available for budgeting. The total Δv escape_dv + capture_dv represents the full departure and arrival cost. The corresponding fuel is computed via Tsiolkovsky: lambert_fuel = m₀ × (1 - exp(-(escape_dv + capture_dv) / v_e)), capped at available fuel. The Lambert solver uses the Hohmann TOF estimate, so the resulting Δv represents the minimum-energy transfer cost. Efficient mode: fuel_budget is set to lambert_fuel — the Lambert Δv with Hohmann TOF IS the efficient budget, replacing the same-SOI _min_fuel_for_time solver which is physically meaningless across gravity wells. Fastest mode: fuel_budget keeps all available fuel; lambert_fuel acts as a floor only (raised if below Lambert, never lowered). Other modes (e.g., max_fuel_fraction): lambert_fuel acts as a floor — the budget is raised to at least lambert_fuel to ensure enough fuel for escape, but never lowered. No 2× multiplier is needed because escape_dv + capture_dv is the total Δv (not half), and cross-SOI execution uses escape→interplanetary→capture phases rather than the BCB burn1/coast/burn2 split. No 1.1× gravity loss margin is needed because escape_delta_v() uses vis-viva which correctly accounts for the hyperbolic excess velocity. The Lambert budget is stored as lambert_dv_budget in the maneuver hash for diagnostics.

Lambert parent-child fix: When the departure or arrival body IS the common parent (e.g., Earth→Luna where common parent = Earth), the body’s position relative to itself is (0,0,0) — a degenerate Lambert input. Fix: when the departure body equals the common parent, use the ship’s actual position (not body center) as r1; when the target body equals the common parent, use the target’s actual position as r2. This correctly represents the ship/target’s distance from the common parent’s center. Body velocities are similarly adjusted.

Efficient mode skip for cross-SOI: The _min_fuel_for_time distance-based minimum fuel solver is skipped for cross-SOI transfers in efficient mode. The straight-line distance solver is physically meaningless for cross-SOI transfers where the ship must traverse a gravity well. The Lambert-based fuel budget provides the correct minimum.

Fallback: When Lambert fails (solver returns None), a simple v_esc - v_ship fallback is used (no 1.1× margin, no 2× multiplier). This should rarely trigger after the parent-child fix.

Gravity-corrected distance estimation: The BCB planner replaces straight-line Euclidean distance with a Kepler-propagated miss distance for computing burn times, coast duration, and fuel budgets. The ship and target are Kepler-propagated forward by t_est (the constant-acceleration brachistochrone time estimate) using the same frame selection as ZEM/ZEV guidance: same-SOI → reference body frame with local mu; cross-SOI → common parent frame with parent mu (via find_common_parent). The propagated separation replaces ctx.distance in all downstream BCB planner inputs (_tsiolkovsky_bcb_plan, _min_fuel_for_time, status text). If propagation fails or returns non-finite values, the planner falls back to straight-line distance. The corrected distance is stored as brach_gravity_corrected_dist in the maneuver hash for diagnostics.

Tsiolkovsky BCB planner: The transfer plan uses Tsiolkovsky rocket dynamics (variable acceleration due to mass change) to compute asymmetric burn durations. burn1 is longer than burn2 because the ship is heavier during acceleration (more fuel) and lighter during braking. The planner uses binary search to find the burn1 duration where d_burn1 + d_coast + d_burn2 = distance, accounting for the exponential velocity profile v(t) = v_e × ln(m₀/(m₀ - ṁt)) and corresponding distance integrals. Fuel is split equally by mass between burn1 and burn2. The result is stored as separate brach_burn_time (burn1) and brach_burn2_time (burn2) fields.

Alignment dead-time budget: The BCB planner accepts an optional dead_time parameter representing the total time lost to attitude slews during which the alignment gate prevents thrusting. Dead time is split equally across burn1 and burn2: max_burn1_time = fuel / (2 × ṁ) - dead_time / 2. Dead time is computed at the call site:

  • T_settle = 4/ω_n per direction change (from attitude controller natural frequency)
  • n_changes = max(1, t_rough / step_hold_interval) where step_hold_interval = 60s
  • dead_time = n_changes × T_settle

This ensures the fuel budget accounts for the time cost of the alignment gate, closing the gap between planned and actual fuel consumption.

Dynamic braking trigger: Instead of using pre-computed timing for the coast-to-burn2 transition, the tick engine continuously monitors remaining Δv capacity during coast. When dv_remaining ≤ rel_vel × 1.15, burn2 begins immediately. The 15% margin accounts for gravity acceleration during the braking burn itself. This replaces the previous time-based transition and dynamic fuel replan, which couldn’t account for gravitational perturbations that alter coast velocity. A fallback time-based transition (elapsed >= burn_time + coast_time) fires if the fuel trigger never activates.

Burn2 abort conditions: During burn2, the maneuver is aborted if any of these conditions hold:

  1. Time overrun: elapsed > total_time × 3.0 — the plan has been tripled, indicating a failed trajectory
  2. Fuel exhaustion with large Δv deficit: fuel < 1.0 is already handled (coasts with retrograde attitude), but if remaining_dv < rel_vel × 0.15 and distance > BRACH_APPROACH_DIST, the trajectory is infeasible — abort rather than waste remaining fuel
  3. Divergence: If distance is increasing while in burn2 and elapsed > total_time × 1.5, the ship has overshot — abort

On abort, the maneuver is deactivated, thrust is set to zero, attitude is set to retrograde (relative to target), and a status message is sent to the client.

Timing formulas: The Tsiolkovsky planner computes asymmetric burn durations via binary search. Approximate relationships (given coast ratio c, distance d, acceleration a):

Parameter Formula
Burn1 time Computed by Tsiolkovsky planner (binary search)
Burn2 time Shorter than burn1 (ship is lighter, decelerates faster)
Coast time d - d_burn1 - d_burn2 at coast velocity
Total time t_burn1 + t_coast + t_burn2
Total Δv v_e × ln(m₀/m_f1) + v_e × ln(m₀₂/m_f2)

Sub-phase flow:

  1. burn1 (elapsed < burn_time): Base throttle = 1.0, gated by alignment: if alignment_angle > ALIGNMENT_GATE_DEG (5°), throttle = 0. This prevents misaligned thrust from perturbing the trajectory when the attitude controller hasn’t yet converged (guidance-attitude decoupling). Dynamic braking guard: if dv_remaining ≤ rel_vel × 1.5 (and rel_vel > 100 m/s, elapsed > 2 × dt_game), transition directly to burn2, skipping coast. This prevents burn1 from building excessive velocity in a gravity well where the BCB straight-line plan underestimates gravity’s contribution. The 1.5× margin (vs coast’s 1.15×) provides extra headroom since burn1 is still accelerating. Direction depends on orbital state:
    • Bound orbit (specific orbital energy < 0): Override direction to prograde (velocity direction relative to reference body). ZEM/ZEV points toward the interplanetary target, which is a fixed direction in the Sun frame. In a parking orbit this alternates between local prograde and retrograde as the ship orbits, producing near-zero net escape Δv and degrading the orbit. Prograde maximizes the Oberth effect and efficiently escapes the gravity well. During prograde escape, step-and-hold is bypassed — direction tracks velocity every tick since the orbital velocity direction changes continuously.
    • Escape hysteresis: Once prograde escape activates, it stays active until the ship exceeds 1.2× escape velocity (v² > 1.44 × 2μ/r). Without hysteresis, the ship oscillates between bound/unbound states near escape velocity — ZEM/ZEV commands a non-prograde direction, the ship decelerates back to bound, prograde escape re-engages, etc. The 20% margin ensures the ship is firmly on an escape trajectory before ZEM/ZEV takes over. State tracked via brach_prograde_escape in the maneuver hash.
    • Unbound orbit (specific orbital energy ≥ 0, past hysteresis threshold): Use ZEM/ZEV direction. The ship has escaped the local body and ZEM/ZEV steers toward the interplanetary intercept point.
  2. coast (after burn1 completes): Throttle = 0. Before coast midpoint: hold current direction. After midpoint: command ATTITUDE_TARGET_RETROGRADE to prepare for braking. Transition to burn2 via dynamic braking trigger (dv_remaining ≤ rel_vel × 1.15), with time-based fallback.
  3. burn2 (triggered dynamically or by time fallback): ZEM/ZEV guidance for both direction and throttle magnitude. Throttle is min(a_cmd / max_accel, 1.0), gated by alignment (zero if > 5°). Unlike burn1, throttle is NOT forced to 1.0. Additionally, a velocity overshoot guard caps throttle so the applied Δv per tick never exceeds the current relative velocity: throttle = min(throttle, rel_vel / (max_accel × dt_game)). This prevents the ship from overshooting zero velocity and oscillating at high time scales (e.g. at 50x, dv_per_tick=3750 m/s would vastly overshoot a 6 m/s relative velocity without the guard).

Time-scale-aware tgo management: The tgo (time-to-go) floor includes dt_game to ensure ZEM/ZEV never plans for less than one guidance update cycle: tgo = max(tgo - dt_game, t_est × 0.5, dt_game, 1.0). At high time scales, this prevents wildly aggressive acceleration commands from small tgo values that the ship can’t correct until the next tick.

Guidance-attitude decoupling (two mechanisms):

  1. Alignment gate: Without alignment gating, misaligned thrust perturbs the trajectory, which changes the ZEM/ZEV direction, which causes the attitude controller to chase a moving target — a positive feedback loop that causes persistent spin. The alignment gate breaks this loop: when the ship is misaligned beyond 5°, thrust is zero, trajectory perturbation is eliminated, and the attitude controller can settle before thrust resumes. This also eliminates the fuel efficiency losses of the previous cosine scaling approach — fuel is only burned when thrust is useful. This is consistent with all other guidance modes (Q-law, circularize, capture) which use the same alignment gate pattern.

  2. Step-and-hold direction updates: Instead of updating the ZEM/ZEV direction every tick (which forces continuous rotation), the direction is held fixed and updated periodically. The update interval scales with tgo: clamp(tgo/100, 10, 60) seconds. Between updates the ship aligns and thrusts at full efficiency. When spinning (|ω| > ω_n/2), the direction is frozen so the controller can damp the spin. The direction and its age are stored in the maneuver hash (brach_dir_x/y/z, brach_dir_age).

Escape velocity guard (same-SOI only): After computing the BCB plan, if BOTH conditions are met, the brachistochrone strategy is abandoned for express orbital transfer (5× apogee factor):

  1. v_coast > 0.8 × v_escape — the BCB would go hyperbolic
  2. BCB_Δv > 20 × orbital_Δv_estimate — the BCB is wildly disproportionate to the actual orbit change needed (where orbital_Δv_estimate = 2 × |v_circ(r_ship) - v_circ(r_target)|)

Both criteria prevent false triggers: condition 1 alone would fire on large orbit changes (LEO→GEO) where brachistochrone works fine despite high v_coast; condition 2 alone would fire on cross-SOI transfers or deep-space maneuvers. Together they identify same-body transfers where the straight-line BCB distance (dominated by orbital phase difference) vastly overestimates the orbital Δv needed. Express keeps the transfer orbit bound while being fast (~80% time reduction vs Hohmann). The fallback is logged as a warning. Does not apply to cross-SOI transfers.

Approach handoff: Same criteria as pure brachistochrone — applies regardless of sub-phase.

Ship class ISP (seconds, used for Δv computation):

Ship Class Isp (s)
cargo_hauler 15,000
fast_frigate 20,000
long_range_explorer 50,000

Maneuver hash fields (when c > 0):

Field Type Description
brach_coast_ratio string (float) Coast ratio (0–0.9999)
brach_prograde_escape string (“0”/”1”) Prograde escape hysteresis flag
brach_burn_time string (seconds) Duration of burn1 phase
brach_burn2_time string (seconds) Duration of burn2 phase (shorter, ship is lighter)
brach_coast_time string (seconds) Duration of coast phase
brach_total_time string (seconds) Total transfer time
brach_elapsed string (seconds) Time elapsed since transfer start
brach_sub_phase string Current sub-phase: "burn1", "coast", or "burn2"

Status text format:

Sub-phase Format
burn1 "Brachistochrone BURN 1 (Xkm, tgo Ys)"
coast "Brachistochrone COAST (Xkm, tgo Ys)"
burn2 "Brachistochrone BURN 2 (Xkm, tgo Ys)"

Timeline UI: Horizontal bar showing BURN1/COAST/BURN2 segments with widths proportional to burn_time, coast_time, burn_time. White vertical line indicates progress (elapsed / total_time). Phase label and remaining time displayed below.

Cross-Body Transfers

When the ship and target orbit different reference bodies (e.g., ship at Earth, target at Luna), ZEM/ZEV Kepler propagation uses their lowest common ancestor (LCA) body’s frame and gravitational parameter instead of the ship’s current reference body.

Common parent resolution (find_common_parent(body_a, body_b)):

  • Walks the BODY_PARENTS hierarchy from each body to the root (Sun)
  • Returns the first ancestor shared by both bodies
  • Examples: Earth↔Luna → Earth. Earth↔Mars → Sun. Io↔Europa → Jupiter.

Propagation logic each tick:

  1. Determine target’s reference body via _find_reference_body(target_pos, body_positions)
  2. If target ref body differs from ship ref body: a. Find common parent via find_common_parent() b. Compute both ship and target positions/velocities relative to common parent c. Use common parent’s μ for Kepler propagation
  3. If same body: use existing body-relative propagation (unchanged)
  4. ship_rel/ship_vrel remain relative to ship’s current ref body for periapsis protection

SOI transition handling (#494): The central maneuver dispatcher (_execute_maneuver_tick) detects SOI transitions by comparing the ship’s current reference body (from _ref_body in context, computed by _find_reference_body()) against the maneuver’s stored ref_body. When they differ:

  1. Update maneuver["ref_body"] to the new body
  2. Reload body position, velocity, mass, radius, and μ from the new body’s data
  3. Recompute orbital elements relative to the new body
  4. Log the transition with old and new body names

This runs before any phase-specific handler, so all maneuver types (circularize, set_inclination, rendezvous/brachistochrone) automatically get SOI transition support. Same-SOI maneuvers are unaffected — the comparison never triggers.

Status text: Cross-body transfers display "Brachistochrone → {target_ref_body} (distance, tgo)".

Why this works: ZEM/ZEV recomputes every tick, so inaccuracies self-correct. The common parent’s μ is the dominant gravitational influence during the transit phase. Periapsis protection remains body-relative and handles safety near departure/arrival bodies.

SOI Chain Computation

soi_chain(source_body, target_body) in automation_orbital.py computes the ordered sequence of SOI transitions a ship must traverse to reach a target in a different SOI.

Algorithm:

  1. Walk BODY_PARENTS from source_body to the root (Sun), collecting ancestors
  2. Walk BODY_PARENTS from target_body to the root, collecting ancestors
  3. Find the lowest common ancestor (LCA)
  4. Build chain: source_body → ancestors up to LCA → ancestors down to target_body

Examples:

  • soi_chain("Earth", "Mars")["Earth", "Sun", "Mars"]
  • soi_chain("Luna", "Mars")["Luna", "Earth", "Sun", "Mars"]
  • soi_chain("Io", "Europa")["Io", "Jupiter", "Europa"]
  • soi_chain("Earth", "Luna")["Earth", "Luna"] (parent→child, no intermediate)
  • soi_chain("Earth", "Earth")["Earth"] (same body)

Returns: List of body names from source to target, inclusive.

Cross-SOI Transfer Planning (#495)

When a rendezvous maneuver starts and the ship and target are in different SOIs, the _phase_transfer_plan() handler detects the mismatch and computes a multi-phase transfer plan using patched-conic approximation. This detection is strategy-agnostic — all strategies (hohmann, fast, express, parabolic, lambert, brachistochrone) use the same cross-SOI planning logic and route through the same escape → interplanetary → capture pipeline.

Detection (in _phase_transfer_plan(), before strategy-specific logic):

  1. Determine target’s reference body via _find_reference_body(target_pos, body_positions)
  2. Compare with ship’s ref_body_name
  3. If same SOI → proceed with existing single-body logic (no change)
  4. If different SOI → compute cross-SOI plan via _compute_cross_soi_plan()

Planning (for 2-body chains like Earth → Sun → Mars):

The shared helper _compute_cross_soi_plan(ctx, maneuver, body_positions) computes:

  1. Find common parent via find_common_parent(ship_ref_body, target_ref_body)
  2. Compute positions/velocities of both ship’s ref body and target’s ref body relative to common parent
  3. Call plan_transfer() from trajectory_planner.py for optimized departure window search with forward integration verification (see Trajectory Planning spec)
  4. If planner succeeds, use planner results for escape/capture Δv; otherwise fall back to single Lambert
  5. escape_delta_v bypass: When ref_body == common, Lambert Δv IS the departure Δv — escape_delta_v() is skipped (avoids double-counting). Same for capture when target_ref == common.
  6. Solve Lambert in common parent frame: lambert_solve(r1_body, r2_body, tof, mu_parent)
  7. Compute departure v_infinity: v_inf_dep = |v_lambert_dep - v_ship_body| (ship’s body velocity relative to parent)
  8. Compute arrival v_infinity: v_inf_arr = |v_lambert_arr - v_target_body| (target’s body velocity relative to parent)
  9. Compute escape delta-v (if ref_body ≠ common): escape_delta_v(r_parking, v_inf_dep, mu_ship_body)
  10. Compute capture delta-v (if target_ref ≠ common): escape_delta_v(r_target_parking, v_inf_arr, mu_target_body)

Returns True if cross-SOI plan was stored, False if not cross-SOI or planning failed.

Maneuver hash fields stored:

Field Type Description
transfer_type string "cross_soi" for cross-SOI transfers
soi_chain string Comma-separated body names, e.g. "Earth,Sun,Mars"
current_phase_index string (int) Index into SOI chain (0 = at source)
escape_v_inf string (m/s) Required v_infinity to match interplanetary transfer
escape_v_inf_vec string (JSON) Departure v_infinity vector in parent frame
capture_v_inf string (m/s) Arrival v_infinity at target body
capture_v_inf_vec string (JSON) Arrival v_infinity vector in parent frame
interplanetary_tof string (seconds) Planned interplanetary time of flight
escape_dv string (m/s) Required Δv for escape burn
capture_dv string (m/s) Required Δv for capture burn
planner_verified string (“True”/”False”) Whether forward integration confirmed SOI entry (#653)
planner_departure_offset string (seconds) Optimal departure delay from current time (#653)

Phase flow (cross-SOI, all strategies):

transfer_plan → escape → interplanetary → capture → circularize → phase → approach → complete

After capture and circularize, the existing same-SOI rendezvous pipeline (phase, approach) handles final rendezvous regardless of the original strategy.

The cross-SOI plan enriches the existing brachistochrone phase with escape/capture energy data. The brachistochrone phase already handles cross-body propagation via ZEM/ZEV in the common parent frame (#494). The stored v_infinity data enables:

  • Pre-flight feasibility checks (enough fuel for escape + transfer + capture)
  • Status text showing interplanetary context
  • Future ESCAPE phase implementation (#496)

Feasibility check: After computing the plan, verify total Δv budget:

  1. dv_escape = escape_delta_v(r_parking, v_inf_dep, mu_ship_body)
  2. dv_capture = escape_delta_v(r_target_parking, v_inf_arr, mu_target_body)
  3. dv_available = Isp × g₀ × ln(m_total / m_dry)
  4. If dv_escape + dv_capture > dv_available: abort with status “Transfer infeasible” (ship cannot cover minimum escape + capture Δv)

Capture fuel reservation (cross-SOI): Before computing the BCB plan, reserve fuel for the capture burn at the destination. capture_fuel = m_dry × (e^(capture_dv / ve) - 1). The BCB planner receives fuel_budget = min(fuel_budget, fuel - capture_fuel), ensuring the escape/transit portion never consumes the capture budget. The capture_fuel_reserved field is stored in the maneuver for status display.

Same-SOI transfers: Completely unchanged — the detection check gates all cross-SOI logic.

Escape Energy Computations

Utility functions in orbital.py for computing hyperbolic escape parameters. Used by cross-SOI transfer planning (#495) and the ESCAPE phase (#496).

compute_v_infinity(semi_major_axis, mu): Hyperbolic excess velocity — the speed a ship retains at infinity (SOI boundary) on an escape trajectory.

  • Formula: v_inf = √(μ / |a|) where a is the (negative) semi-major axis of a hyperbolic orbit
  • Returns 0.0 for non-escape orbits (a ≥ 0)

compute_c3(v_infinity): Characteristic energy — the square of v_infinity. Standard measure of escape energy in astrodynamics.

  • Formula: C3 = v_inf²
  • Equivalent: C3 = -μ / a (for hyperbolic orbits)
  • Units: m²/s²

escape_delta_v(r_parking, v_inf_required, mu): Delta-v to transition from a circular parking orbit to a hyperbolic escape with target v_infinity.

  • Parking orbit velocity: v_circ = √(μ / r)
  • Required velocity at periapsis (vis-viva): v_depart = √(2μ/r + v_inf²)
  • Delta-v: Δv = v_depart - v_circ
  • This is the minimum (tangential) burn; real burns may differ due to plane changes

v_infinity field in calculate_orbital_elements(): When an escape trajectory is detected (e ≥ 1 or ε ≥ 0), the returned dict includes v_infinity computed from the hyperbolic semi-major axis. For bound orbits, v_infinity is None.

ESCAPE Phase (#496)

When a cross-SOI transfer is detected (#495), the ship must first escape its departure body’s SOI. The ESCAPE phase burns prograde (relative to the departure body) until achieving escape energy, then transitions to the interplanetary transfer phase.

Phase flow (cross-SOI with escape):

transfer_plan → escape → interplanetary → [capture] → [circularize] → [phase] → approach → complete

Pre-flight Δv feasibility (#737): Before entering the ESCAPE phase, all strategies (brachistochrone, hohmann, fast, express, parabolic, lambert) perform a Δv abort gate: if escape_dv + capture_dv > dv_available (computed from the Tsiolkovsky rocket equation), the maneuver is aborted with status "Transfer infeasible — need X m/s, have Y m/s". This prevents ships with insufficient fuel from entering escape burns they cannot complete.

Entry condition: transfer_type == "cross_soi" in maneuver hash. The _phase_transfer_plan() handler transitions to "escape" instead of "brachistochrone" when a cross-SOI plan is present.

Guidance (each tick):

  1. Compute velocity relative to departure body: v_rel = v_ship - v_body
  2. Compute specific orbital energy: E = 0.5 × |v_rel|² − μ/r
  3. Set thrust direction to prograde (relative to departure body): dir = normalize(v_rel) via ATTITUDE_DIRECTION
  4. Alignment gate: if alignment_angle > ALIGNMENT_GATE_DEG (5°), throttle = 0 — prevents full-power burns before the attitude controller has settled
  5. Periapsis protection remains active (prevents crashing during low-altitude escape burns)

Exit condition (v∞-aware, #738):

The exit condition depends on whether escape_v_inf is available in the maneuver hash (set by _compute_cross_soi_plan):

When escape_v_inf is present:

  • Nested SOI, sub-escape target (escape_nested_soi == "1" and escape_v_inf² < 2μ/r): Transfer orbit is elliptical — energy will never reach zero. Exit when |v_rel| ≥ escape_v_inf. The escape_v_inf value is the total departure velocity in the parent frame.
  • Sibling SOI (escape_nested_soi == "0"): Transfer orbit is hyperbolic. Exit when orbit_energy ≥ 0.5 × escape_v_inf² × 1.05 (5% margin). The escape_v_inf value is v∞ at infinity.
  • Nested SOI, above-escape target (escape_nested_soi == "1" and escape_v_inf² ≥ 2μ/r): Fast brachistochrone transfer where planned velocity exceeds escape. Falls through to standard energy exit: E ≥ 0 with v² ≥ 1.2 × 2μ/r.

Fallback (no escape_v_inf): E ≥ 0 with hysteresis v² ≥ 1.2 × 2μ/r (original behavior for backward compatibility).

Post-escape transition:

When escape completes:

  1. Transition to "interplanetary" phase (when transfer_type == "cross_soi")
  2. Dynamic ref_body tracking (#494) will automatically update ref_body when the ship crosses the SOI boundary

SOI guard interaction: The existing brach_prograde_escape hysteresis in the brachistochrone phase handles escape from bound orbits during burn1. The explicit ESCAPE phase provides a dedicated, cleaner mechanism for cross-SOI transfers with proper phase accounting and status text.

Maneuver hash fields:

Field Type Description
escape_start_energy string (J/kg) Specific orbital energy at escape phase entry
escape_v_inf string (m/s) Target departure velocity (nested) or v∞ (sibling) — set by _compute_cross_soi_plan
escape_nested_soi string (“1”/”0”) Whether departure body is the common parent (nested SOI transfer)

Status text:

State Format
escape (bound) "ESCAPE {body}: burning to leave SOI (E={X.X} MJ/kg)"
escape (unbound) "ESCAPE {body}: achieved escape velocity" (transitions next tick)
INTERPLANETARY Phase (#497)

After escaping the departure body’s SOI, the ship enters the INTERPLANETARY phase — a parent-body-relative (typically heliocentric) transfer that uses ZEM/ZEV guidance to fly toward the target body and detect arrival at the target’s SOI.

Entry condition: Transition from ESCAPE phase when transfer_type == "cross_soi". The ship is now on a heliocentric trajectory with ref_body = common parent (e.g., Sun).

Guidance (each tick):

  1. Identify target body: Extract from soi_chain (e.g., "Earth,Sun,Mars" → target body = "Mars")
  2. Pre-departure coast (#921): For non-brachistochrone strategies, if interplanetary_tgo > interplanetary_tof × 1.1 (ship is still waiting for the departure window, with 10% margin), coast with zero thrust in prograde attitude. ZEM/ZEV guidance should NOT run during the parking orbit wait — even tiny commanded accelerations waste fuel (0.38% throttle × 12 days = 17% of fuel) and perturb the orbit.
  3. Compute target body position/velocity from _body_positions context
  4. Common parent frame: Determine common parent via find_common_parent(chain[0], chain[-1]) — for Earth↔Luna transfers this returns Earth (not Sun). Compute ship and target body positions/velocities relative to the common parent body
  5. ZEM/ZEV guidance: Use the same Kepler-propagated ZEM/ZEV feedback law as brachistochrone:
    • Propagate ship and target body by tgo using kepler_propagate() in common parent frame
    • a_cmd = 6×ZEM/tgo² + 2×ZEV/tgo
    • Direction = normalize(a_cmd), throttle = min( a_cmd /max_accel, 1.0)
  6. Time-to-go management: Initialize from brach_t_estimate × 1.5 when available (brachistochrone flight-time estimate), fall back to interplanetary_tof × 1.5 (Lambert solution). Decay by dt_game each tick. Adaptive distance-based floor: When the target body position is known, tgo floors at brachistochrone_time_estimate(dist_to_target_body, max_accel) × 0.5 — this adapts to the remaining distance rather than the full-transfer estimate, preventing massive tgo overestimation near the target (e.g., 12,400s floor when only 100s remain at 60,000 km from Luna). When far from target or target body data unavailable, falls back to max(brach_t_estimate × 0.5, 60s). Absolute minimum: max(dt_game, 1.0) (prevents divide-by-zero and ensures at least one guidance cycle)
  7. Alignment gate: Zero thrust when misaligned beyond 5° (same as brachistochrone)

Exit condition: Ship enters target body’s SOI:

  • Compute distance from ship to target body
  • Compute SOI radius of target body via compute_soi_radius()
  • Departure SOI guard (sibling transfers only): For sibling transfers (e.g., Earth→Mars, both orbiting Sun), the target SOI check is suppressed while the ship is still inside the departure body’s SOI. This prevents premature capture transition — without this guard, a ship leaving Earth’s SOI would spuriously match when crossing distant SOI boundaries. The guard uses soi_chain[0] as the departure body and checks distance_to_departure < departure_soi_radius. The guard is skipped for parent↔child transfers (e.g., Earth→Luna, Luna→Earth, Jupiter→Io) because the target SOI is nested inside the departure SOI and the ship can never “leave” the outer SOI (#720).
  • Capture trigger evaluation (at SOI entry): Evaluate specific orbital energy and strategy to determine next phase:
    1. Compute v_rel, specific orbital energy E = 0.5 × v_rel² − μ/r
    2. If E < 0 and apoapsis within SOI:
      • strategy == "brachistochrone" → transition to brachistochrone phase (ZEM/ZEV in target body frame)
      • Otherwise → transition to stabilize phase
    3. If E < 0 but apoapsis beyond SOI:
      • strategy == "brachistochrone"replan BCB from remaining distance, fuel, and relative velocity, then transition to brachistochrone phase (ZEM/ZEV in target body frame). The BCB replan uses _tsiolkovsky_bcb_plan() with the current distance to target and remaining fuel, producing correct brach_burn_time, brach_coast_time, brach_coast_ratio, and brach_tgo. This preserves the BCB state machine (burn1→coast→burn2 transitions) instead of bypassing it with brach_coast_ratio=0. Velocity-aware time estimate (#919): When the ship has significant relative velocity at SOI entry, the BCB time estimate must account for the deceleration phase. The brach_total_time is set to max(bcb_plan_time, velocity_adjusted_time) where the velocity-adjusted time includes: (a) deceleration time v₀/a, (b) if stopping distance v₀²/(2a) > dist: additional turnaround time 2×√(overshoot/a), else: BCB time for remaining distance after deceleration. The brach_burn_time (burn1) is set to 0 when initial velocity exceeds the BCB coast velocity, since the ship is already moving fast and needs to decelerate (not accelerate).
      • Otherwise → enter capture phase (retrograde braking)
    4. If E ≥ 0:
      • strategy == "brachistochrone"replan BCB (same as case 3), then transition to brachistochrone phase
      • Otherwise → enter capture phase (retrograde braking)
  • Update maneuver ref_body to target body name

Why brachistochrone skips capture: Brachistochrone transfers use continuous-thrust ZEM/ZEV guidance that actively decelerates throughout approach. Unlike Hohmann/Lambert transfers that arrive on a ballistic trajectory with uncontrolled v_inf, brachistochrone ships are already managing their velocity. Routing them through the capture phase (retrograde burn using Lambert-computed capture_v_inf) discards ZEM/ZEV guidance state and uses incorrect velocity targets, causing surface impacts. On SOI entry, the BCB plan is recomputed from the remaining distance, fuel, and relative velocity via _replan_brachistochrone(), preserving the burn1→coast→burn2 state machine. The relative velocity is critical (#919): a ship entering Luna’s SOI at 8.8 km/s needs ~15,000 km to decelerate at 2.5 m/s², but a rest-to-rest BCB plan for 10,000 km distance only budgets ~3,900s — leading to a time overrun abort when the ship inevitably overshoots.

Why non-brachistochrone uses capture (#723): ZEM/ZEV guidance for Hohmann/Lambert transfers operates in the parent frame (e.g., Earth frame for Earth→Luna) and does not model the target body’s local gravity well. Continuing ZEM/ZEV inside the target SOI with hyperbolic excess velocity causes the ship to accelerate toward the body surface faster than the guidance can correct, resulting in crashes. The capture phase uses body-relative retrograde braking, which is the correct algorithm for decelerating within a gravity well regardless of v_inf magnitude.

Approach distance exclusion: The interplanetary phase is excluded from the generic approach distance check (like escape and brachistochrone), since approach distance thresholds are inappropriate at interplanetary scale.

Maneuver hash fields (read from transfer plan):

Field Type Description
soi_chain string Comma-separated SOI chain (e.g., "Earth,Sun,Mars")
interplanetary_tof string (seconds) Estimated time of flight from Lambert solution
interplanetary_tgo string (seconds) Current time-to-go (decays each tick)
brach_t_estimate string (seconds) Brachistochrone flight-time estimate (used for tgo init/floor)

Status text:

State Format
cruising "INTERPLANETARY: {from} → {to} ({dist} AU, tgo {eta})"
SOI entry "INTERPLANETARY: entering {body} SOI" (transitions next tick)
CAPTURE Phase (#498)

When a ship arrives at the target body’s sphere of influence on a hyperbolic trajectory (from the INTERPLANETARY phase), it must brake into a bound orbit before rendezvous. High-TWR ships (e.g., Fast Frigate at 36 m/s²) can decelerate from interplanetary velocities within ~15,000 km. Low-TWR ships (e.g., Cargo Hauler at 2.5 m/s², Long-Range Explorer at 1.18 m/s²) require hundreds of thousands of km — exceeding typical SOI radii (Luna: ~59,000 km).

The CAPTURE phase inserts an orbit-capture burn between the interplanetary transfer and the final approach. Instead of flying directly at the target, the ship:

  1. Burns retrograde (relative to the arrival body) to shed hyperbolic excess velocity
  2. Achieves a bound orbit (specific orbital energy < 0)
  3. Circularizes to a parking orbit near the target’s altitude
  4. Performs standard phase/approach rendezvous

This mirrors real spacecraft orbit insertion (Mars Orbit Insertion, Lunar Orbit Insertion).

Updated phase flow (cross-SOI transfers with capture):

transfer_plan → escape → interplanetary → capture → circularize → phase → approach → complete

Without capture (same-body or high-TWR cross-body):

transfer_plan → brachistochrone → approach → complete

Trigger criteria:

When the ship’s reference body transitions to the target’s reference body (SOI entry), the guidance evaluates specific orbital energy:

  1. Compute speed relative to body: v_rel = |v_ship - v_body|
  2. Compute specific orbital energy: E = 0.5 × v_rel² − μ/r
  3. Check strategy: strategy = maneuver.get("strategy", "hohmann")
  4. If E < 0: a. Compute apoapsis radius ra from position/velocity vectors b. If ra ≤ SOI_radius: stable bound orbit — route by strategy (brachistochrone → brachistochrone phase, otherwise → stabilize) c. If ra > SOI_radius: marginally bound, orbit extends beyond SOI — route by strategy (brachistochrone → brachistochrone phase with brach_coast_ratio=0, brach_tgo=0; otherwise → capture phase)
  5. If E ≥ 0: route by strategy (brachistochrone → brachistochrone phase with brach_coast_ratio=0, brach_tgo=0; otherwise → capture phase)

Non-brachistochrone ships with positive orbital energy at SOI entry must use the capture phase for retrograde braking. Brachistochrone ships continue ZEM/ZEV guidance in the target body’s frame, with BCB disabled (brach_coast_ratio=0) and tgo recomputed from remaining distance (brach_tgo=0).

Capture phase behavior:

Each tick during capture:

  1. Compute velocity relative to reference body: v_rel = v_ship - v_body
  2. Set thrust direction to retrograde: dir = -normalize(v_rel) via ATTITUDE_DIRECTION
  3. Alignment gate: if alignment_angle > ALIGNMENT_GATE_DEG (5°), throttle = 0 — prevents full-power burns before the attitude controller has settled
  4. Compute specific orbital energy: E = 0.5 × |v_rel|² − μ/r
  5. Periapsis protection remains active (prevents crashing during capture)

Exit condition: E < 0 AND apoapsis ≤ SOI_radius (specific orbital energy negative AND the resulting orbit fits within the sphere of influence). A marginally bound orbit (e ≈ 1) can have an apoapsis extending far beyond the SOI, making it unstable under multi-body perturbations and unsuitable as input for the circularize phase. The capture burn continues retrograde until the apoapsis is pulled within the SOI.

SOI escape fallback: If the ship exits the target body’s SOI during capture (velocity too high to arrest before reaching the SOI boundary), the capture phase detects r_rel > SOI_radius and reverts to the interplanetary phase. This handles edge cases where the arrival velocity exceeds what the ship can brake within the SOI, allowing the interplanetary ZEM/ZEV guidance to re-approach.

Post-capture transition:

When capture completes (bound orbit achieved):

  1. Compute the target station’s orbital elements (SMA, eccentricity)
  2. Set transfer_target_r to the target’s SMA
  3. Transition to circularize phase
  4. After circularization, transition to phaseapproachcomplete via existing rendezvous state machine

The circularize phase uses the existing Q-law algorithm to match the target orbit altitude. The phase/approach pipeline handles phasing and final docking.

Fuel considerations:

The capture burn Δv equals the hyperbolic excess velocity: Δv_capture ≈ v_inf. The BCB transfer_plan should account for this when computing the coast ratio:

  1. Compute expected arrival v_inf from the planned transfer trajectory
  2. Add Δv_capture to the required Δv budget
  3. If total Δv (burn1 + burn2 + capture) exceeds available Δv: increase coast ratio or abort

Maneuver hash fields:

Field Type Description
capture_active string ("true"/"false") Whether capture phase is active
capture_v_inf string (m/s) Hyperbolic excess velocity at capture entry
capture_energy string (J/kg) Current specific orbital energy

Status text:

State Format
capture (unbound) "Capture burn at {body} (v_excess X.Xkm/s, E=Y.Y MJ/kg)"
capture (bound, ra > SOI) "Capture burn at {body} — lowering apoapsis (ra Xkm, SOI Ykm, E=Z.Z MJ/kg)"
capture (bound, ra ≤ SOI) "Captured — circularizing" (transitions next tick)

Periapsis guard interaction: During capture, the standard periapsis guard remains active. The stopping-distance exception does NOT apply during capture (the ship is intentionally decelerating, not overshooting). If periapsis drops below safe altitude during capture, the guard blends thrust toward radial-outward — this is correct behavior, preventing the capture burn from driving the ship into the body.

Prograde escape interaction: The prograde_escape flag (used during BCB burn1 for bound orbits) is mutually exclusive with capture. Prograde escape applies when leaving a body; capture applies when arriving. The SOI transition from departure body to arrival body naturally separates these phases.

Station-Keeping Algorithm

Station-keeping maintains a fixed distance from a target entity. Two phases: approach and hold.

Phases

Phase Entry Condition Behavior
approach abs(distance - hold_distance_m) > SK_APPROACH_THRESHOLD_M Approach/retreat to hold distance using braking-curve speed profile
hold abs(distance - hold_distance_m) ≤ deadband AND rel_vel < SK_VEL_DEADBAND_MS Maintain position within adaptive deadband

Adaptive Deadband

The deadband scales with hold distance:

deadband = clamp(hold_distance_m × 0.05, 10m, 500m)
  • Position deadband: ±deadband meters from hold_distance_m
  • Velocity deadband: 0.5 m/s relative velocity

Constants

Constant Value Description
SK_DEADBAND_FRACTION 0.05 ±5% of hold distance
SK_DEADBAND_MIN_M 10.0 Minimum deadband (meters)
SK_DEADBAND_MAX_M 500.0 Maximum deadband (meters)
SK_VEL_DEADBAND_MS 0.5 Velocity deadband (m/s)
SK_APPROACH_THRESHOLD_M 1000.0 Switch from approach to hold when within this distance of hold_distance
SK_APPROACH_DECEL 1.0 m/s² Deceleration budget for approach braking curve (10× rendezvous to outpace tidal drift)

Approach Phase Algorithm

The approach phase uses a braking-curve speed profile rather than a fixed velocity threshold:

  1. Compute target speed from constant-deceleration braking curve:
    • target_speed = clamp(√(2 × SK_APPROACH_DECEL × dist_error × APPROACH_STOPPING_SAFETY), 0.5, MAX_CLOSING_VEL)
  2. Compare total relative velocity (rel_vel) against target_speed:
    • Brake if rel_vel > target_speed × 1.1: thrust retrograde (oppose all relative velocity — both radial and tangential). Proportional to excess only. This prevents orbiting around the target due to unchecked tangential drift.
    • Accelerate if closing speed < target_speed × 0.9 OR ship is separating: thrust toward target, proportional to velocity deficit.
    • Coast if within ±10% of target speed: no thrust commands.
  3. Transition to hold phase when abs(dist_error) < SK_APPROACH_THRESHOLD_M AND rel_vel < max(rcs_dv_per_tick × 60, 15.0) — approach decelerates to RCS-manageable speeds before handoff.

The braking check uses total relative velocity (not just radial) because tangential velocity is equally dangerous — it causes the ship to orbit the target rather than approach it. The 10% hysteresis band prevents oscillation.

For retreat (too close, dist_error < 0): thrust away from target.

Hold Phase Algorithm

The hold phase uses RCS-only corrections — no main engine. This prevents overshoot from high-thrust ships and provides fine-grained control.

Each tick:

  1. Compute relative position and velocity to target
  2. Compute position error: dist_error = distance - hold_distance_m
  3. If abs(dist_error) < deadband AND rel_vel < SK_VEL_DEADBAND_MS: coast (no thrust commands)
  4. If abs(dist_error) > SK_APPROACH_THRESHOLD_M OR rel_vel > max(rcs_dv_per_tick × 60, 15.0): revert to approach phase (main engine handles large corrections)
  5. Otherwise, 3D RCS velocity matching + braking-curve position correction:
    • Decompose relative velocity into radial (along line of sight) and tangential components
    • Radial: braking-curve target speed = min(√(2 × rcs_accel × overshoot), rcs_dv × 10, 5.0)
    • Tangential: target = 0 (kill all tangential drift to prevent orbital divergence)
    • Compute desired ΔV in world frame: radial correction + tangential kill
    • Construct body frame from d_hat (toward target): Y=d_hat, X=cross(d_hat, ecliptic_pole), Z=cross(X, Y)
    • Project ΔV onto body axes → rcs_x, rcs_y, rcs_z (each clamped to [-1, 1])
    • Attitude: ATTITUDE_TARGET, main engine off
    • This 3D approach prevents tangential drift from causing distance oscillation between ships in different orbits

Target Types

Station-keeping supports all rendezvous target types plus celestial bodies:

  • "ship", "station", "jumpgate", "lagrange": resolved same as rendezvous
  • "body": resolved from body_positions context by body name (e.g., "Earth", "Moon")

Hold Distance Capture

hold_distance_m is computed at activation time from the current distance between ship and target. Stored in the maneuver hash and remains constant for the duration of the maneuver.

Fuel Behavior

Station-keeping has no fuel safety — it stops naturally when fuel runs out. The ship will coast without correction until aborted or fuel is restored (e.g., by refueling).

Landing Algorithm

The landing autopilot autonomously lands a ship on a celestial body. It assumes the ship is in the target body’s SOI with eccentricity < 1 (bound orbit). The autopilot handles deorbit, braking, descent, and touchdown using a hover-descent approach.

Phases

Phase Entry Condition Behavior
deorbit Initial phase Retrograde burn to lower periapsis to ~2 km AGL
braking Periapsis < target altitude Surface retrograde burn to kill horizontal velocity (immediate, no coast)
vertical_descent Horizontal speed < 5 m/s LOCAL_VERTICAL attitude, PID-controlled descent rate
terminal AGL < 500 m Reduce target descent rate as altitude decreases
touchdown AGL < 10 m Gentle constant-thrust final contact

Phase Details

Deorbit Phase

Lower periapsis to bring the ship close to the surface.

  1. Compute current periapsis altitude AGL: pe_agl = periapsis_radius - body_radius
  2. If pe_agl > DEORBIT_TARGET_ALT (2000 m):
    • Set attitude to retrograde
    • Compute alignment angle between ship forward and retrograde direction
    • Apply alignment gate: zero thrust/RCS if angle > ALIGNMENT_GATE_DEG (5°)
    • Standard mode (hover_thrust >= 0.01): Main engine at 100% thrust
    • Micro-gravity mode (hover_thrust < 0.01): RCS retrograde instead of main engine. Main engine at any throttle massively overshoots on bodies with TWR > 2000 (e.g. Deimos TWR ~12,800). RCS +Z proportional to periapsis error: rcs_z = clamp((pe_agl - target) / pe_agl × 2, 0, 1)
    • status_text: "Deorbit — pe {pe_agl}m, target 2km"
  3. When pe_agl <= DEORBIT_TARGET_ALT:
    • Set attitude to surface retrograde (pre-orient for braking)
    • Set thrust to 0
    • Transition directly to braking (no coast phase — start braking immediately)
Braking Phase

Kill horizontal (surface-relative) velocity using surface retrograde attitude.

  1. Compute surface-relative velocity by subtracting body rotation:
    omega_body = (2π / rotation_period) × spin_axis
    spin_axis = [0, sin(axial_tilt), cos(axial_tilt)]  # rotate ecliptic north by tilt
    v_surface = v_orbital - cross(omega_body, r_rel)
    
  2. Decompose into radial and horizontal components
  3. Dynamic braking threshold: On micro-gravity bodies, orbital velocity is low enough that the fixed 5 m/s threshold leaves significant orbital speed unbraked. The threshold scales with local gravity:
    dynamic_threshold = min(BRAKING_COMPLETE_HSPD, max(0.5, sqrt(g_local × 200)))
    

    Examples: Phobos (g=0.004) → 0.89 m/s, Luna (g=1.6) → 5.0 m/s, Mars (g=3.7) → 5.0 m/s

  4. If horizontal_speed > dynamic_threshold:
    • Standard mode (hover_thrust >= 0.01): Surface retrograde attitude + proportional main engine
      • Set attitude to surface retrograde (ATTITUDE_SURFACE_RETROGRADE)
      • Proportional throttle: thrust = clamp(horizontal_speed / 50.0, 0.1, 1.0)
    • Micro-gravity mode (hover_thrust < 0.01): LOCAL_VERTICAL attitude + RCS lateral control
      • Set attitude to local vertical (ATTITUDE_LOCAL_VERTICAL)
      • Main engine thrust = 0
      • RCS x/y: cancel horizontal velocity via _horizontal_to_body_rcs()
      • RCS z: descent rate control (proportional to rate error)
    • status_text: "Braking — hspd {hspd} m/s" or "RCS braking — hspd {hspd} m/s"
  5. When horizontal_speed <= dynamic_threshold:
    • Transition to vertical_descent

Phase regression: If vertical_descent detects high horizontal speed, regress to braking. Threshold: dynamic_threshold × 2 for untargeted landings, dynamic_threshold × 10 (~50 m/s) for targeted landings — small h_speed may be intentional RCS steering toward the target, but large h_speed means braking exited early via safety override. hover_steer also regresses to braking when h_speed > dynamic_threshold × 10. Only on normal-gravity bodies (hover_thrust >= 0.01).

Vertical Descent Phase

Controlled descent at a target rate. Two control modes depending on local gravity:

Standard mode (hover_thrust >= 0.01): SURFACE_RETROGRADE attitude with PID throttle. Surface retrograde naturally corrects horizontal drift from gravity gradient and Coriolis effects during descent.

  1. Set attitude to SURFACE_RETROGRADE (thrust opposes velocity vector relative to surface)
  2. Compute local gravity: g_local = G × body_mass / r²
  3. Compute hover thrust fraction: hover_thrust = (total_mass × g_local) / max_thrust
  4. Target descent rate scales with altitude: target_rate = min(MAX_DESCENT_RATE, max(DESCENT_TARGET_RATE, sqrt(2 × DESCENT_DECEL_BUDGET × AGL))) — fast at high altitude, tapering to 5.0 m/s near surface. Constants: DESCENT_DECEL_BUDGET = 2.0 m/s², MAX_DESCENT_RATE = 2000 m/s
  5. PID controller on descent rate:
    • Error: e = target_rate - actual_descent_rate (positive when descending too slow)
    • thrust = hover_thrust - Kp × e - Ki × integral(e) - Kd × d(e)/dt
    • Clamp thrust to [0.0, 1.0]
  6. status_text: "Descent — AGL {agl}, vspd {vspd} m/s"
  7. When AGL < 500 m: transition to terminal

Micro-gravity mode (hover_thrust < 0.01): On micro-gravity bodies, gravity is too weak to build descent speed — waiting for free-fall is impractical (e.g., 21 minutes to reach 5 m/s on Phobos). The main engine is also far too powerful (TWR >2000), so micro-gravity descent uses RCS translation for fine velocity control:

  1. Attitude: LOCAL_VERTICAL (ship +Z away from body center = upright)
  2. Main engine: thrust = 0 (too powerful for precision descent)
  3. Cap target_rate by gravity-limited speed: min(target_rate, max(DESCENT_TARGET_RATE, sqrt(2 × g_local × AGL)))
  4. RCS translation in body frame:
    • Radial control (z-axis): rcs_z = -clamp((target_rate - descent_rate) × 0.2, -1.0, 1.0) — negative Z pushes ship toward surface
    • Lateral drift correction (x/y axes): proportional to horizontal speed components in body frame
  5. status_text: "Descent (RCS) — AGL {agl}, vspd {vspd} m/s"

RCS linear thrust is typically 2,500 N (planetary lander) giving ~0.5 m/s² — orders of magnitude more controllable than the 50 kN main engine. This matches how real micro-gravity operations (asteroid proximity, station docking) use RCS rather than main propulsion.

PID constants:

Constant Value Description
DESCENT_KP 0.1 Proportional gain
DESCENT_KI 0.01 Integral gain
DESCENT_KD 0.05 Derivative gain
DESCENT_I_MAX 5.0 Integral windup limit
Terminal Phase

Reduce descent rate as altitude decreases, approaching a soft touchdown.

  1. Attitude: LOCAL_VERTICAL (maintained from vertical descent)
  2. Target descent rate: max(min_rate, sqrt(2 × 0.1 × AGL))
    • Standard mode: min_rate = 1.0 m/s
    • Micro-gravity mode: min_rate = 0.3 m/s (slower minimum to avoid surface penetration — RCS can’t decelerate quickly enough at higher rates with negligible gravity assist)
    • AGL ≤ 10 m: transition to touchdown
  3. Same PID controller as vertical descent (standard mode) or RCS z-axis control (micro-gravity mode)
  4. status_text: "Terminal — AGL {agl}, vspd {vspd} m/s"
Touchdown Phase

Final contact with surface.

Standard mode (hover_thrust ≥ 0.01):

  1. Attitude: LOCAL_VERTICAL
  2. Thrust: hover_thrust × 0.95 — slightly less than hover to allow gentle descent
  3. RCS translation zeroed — prevents stale translation inputs from combining with hover thrust to exceed local gravity, which would repeatedly launch and re-land the ship
  4. Below 3m AGL: thrust cut to zero to let ship settle under gravity

Micro-gravity mode (hover_thrust < 0.01):

  1. Attitude: LOCAL_VERTICAL
  2. Main engine off, RCS-controlled descent at 0.2 m/s target rate
  3. Cannot cut thrust and rely on gravity — g is negligible (e.g. 0.004 m/s² on Phobos); cutting thrust would leave the ship drifting at whatever speed it had
  4. RCS z-axis: proportional control with gain 0.5 on rate error

Both modes:

  1. Physics engine handles surface contact detection and landing state transition
  2. When landed_body_name != "": maneuver complete
  3. status_text: "Touchdown — AGL {agl}"

Constants

Constant Value Description
DEORBIT_TARGET_ALT 2000 m Target periapsis AGL for deorbit burn
BRAKING_COMPLETE_HSPD 5.0 m/s Horizontal speed threshold to end braking
DESCENT_TARGET_RATE 5.0 m/s Target descent rate during vertical descent
TOUCHDOWN_THRUST_FRAC 0.95 Fraction of hover thrust during touchdown
ALIGNMENT_GATE_DEG Shared with other maneuvers

Maneuver State Fields

Additional fields stored in maneuver:{ship_id} for landing:

Field Type Description
target_body string Body to land on
phase string Current phase name
pid_integral float PID integral accumulator
pid_last_error float PID previous error for derivative

Fuel Behavior

No fuel safety — the autopilot runs until fuel is exhausted. If fuel runs out during descent, the ship will crash unless it’s already slow enough for a safe landing.

Implementation Files

  • services/tick-engine/src/maneuver_landing.py — Phase handlers (new file)
  • services/tick-engine/src/automation.py — Register land action, dispatch to phase handlers
  • services/tick-engine/src/automation_helpers.py — Add "land" to VALID_ACTIONS
  • services/web-client/src/automationHelpers.js — Add land action to UI

Events

automation.maneuver_complete published to galaxy:automations stream:

event: automation.maneuver_complete
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
maneuver_type: <string>
tick: <int>

automation.maneuver_aborted published to galaxy:automations stream:

event: automation.maneuver_aborted
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
maneuver_type: <string>
tick: <int>

WebSocket Protocol

Cross-Ship Targeting (ship_id parameter)

All automation messages (automation_create, automation_update, automation_delete, automation_list) accept an optional ship_id field. When omitted, the message targets the player’s currently active ship (default behavior). When provided, the server validates that the target ship is owned by the player via player:{player_id}:ships set membership. If the ship is not owned, the server responds with error code E034 (“Ship not owned”).

Response messages (automation_created, automation_updated, automation_deleted, automation_rules) include a ship_id field identifying which ship the rules belong to. The client uses this to route responses to either the automation view (active ship) or the fleet orders panel (other ships).

Client → Server

automation_create

{
  "type": "automation_create",
  "ship_id": "...",
  "name": "Fuel cutoff",
  "mode": "once",
  "priority": 50,
  "trigger": {
    "conditions": [{"field": "ship.fuel", "op": "<", "value": 0.05}],
    "logic": "AND"
  },
  "actions": [
    {"action": "set_thrust", "value": 0},
    {"action": "alert", "message": "Fuel critical — thrust cut"}
  ]
}

ship_id is optional — defaults to active ship if omitted. Server validates, generates UUID, stores in Redis with enabled: false, responds with automation_created. New rules are always created disabled so the player can review configuration before activating.

automation_update

{
  "type": "automation_update",
  "ship_id": "...",
  "rule_id": "...",
  "name": "Updated name",
  "enabled": true,
  "mode": "continuous",
  "priority": 30,
  "trigger": { ... },
  "actions": [ ... ]
}

ship_id is optional — defaults to active ship if omitted. Only provided fields are updated. Server verifies ship ownership, responds with automation_updated.

automation_delete

{
  "type": "automation_delete",
  "ship_id": "...",
  "rule_id": "..."
}

ship_id is optional — defaults to active ship if omitted. Server verifies ownership, deletes hash and removes from index set, responds with automation_deleted.

automation_list

{
  "type": "automation_list",
  "ship_id": "..."
}

ship_id is optional — defaults to active ship if omitted. Server reads all rules for the target ship, responds with automation_rules.

maneuver_query

{
  "type": "maneuver_query"
}

Server reads maneuver:{ship_id} from Redis. Responds with maneuver_status.

maneuver_abort

{
  "type": "maneuver_abort"
}

Server checks if maneuver:{ship_id} exists. If yes, sets abort=true on the hash and responds with maneuver_aborted. If no active maneuver, responds with error E028.

maneuver_pause

{
  "type": "maneuver_pause"
}

Server checks if maneuver:{ship_id} exists. If yes, sets paused="true" and paused_at_tick on the hash and responds with maneuver_paused. If no active maneuver, responds with error E028. If already paused, responds with error E029.

maneuver_resume

{
  "type": "maneuver_resume"
}

Server checks if maneuver:{ship_id} exists. If yes and paused, sets paused="false" and clears paused_at_tick on the hash and responds with maneuver_resumed. If no active maneuver, responds with error E028. If not paused, responds with error E030.

Server → Client

automation_created

{
  "type": "automation_created",
  "ship_id": "...",
  "rule": {
    "rule_id": "...",
    "name": "Fuel cutoff",
    "enabled": true,
    "mode": "once",
    "priority": 50,
    "trigger": { ... },
    "actions": [ ... ],
    "created_at": "2025-01-15T10:30:00Z"
  }
}

automation_updated

{
  "type": "automation_updated",
  "ship_id": "...",
  "rule": { ... }
}

Full rule object after update. ship_id identifies which ship the rule belongs to.

automation_deleted

{
  "type": "automation_deleted",
  "ship_id": "...",
  "rule_id": "..."
}

automation_rules

{
  "type": "automation_rules",
  "ship_id": "...",
  "rules": [ ... ]
}

Array of rule objects sorted by priority (ascending). ship_id identifies which ship the rules belong to.

automation_triggered

{
  "type": "automation_triggered",
  "rule_id": "...",
  "rule_name": "Fuel cutoff",
  "tick": 12345,
  "actions_executed": ["set_thrust(0)", "alert(Fuel critical — thrust cut)"]
}

Sent when a rule fires. Delivered only if the player is connected; lost if offline (acceptable for MVP).

maneuver_status

{
  "type": "maneuver_status",
  "active": true,
  "maneuver": {
    "type": "circularize",
    "ref_body": "Earth",
    "rule_name": "Auto-circ",
    "started_tick": 12345,
    "phase": null,
    "sub_phase": null,
    "target_inclination": null,
    "target_id": null,
    "target_type": null,
    "dock_on_arrival": null,
    "status_text": null,
    "element_errors": null,
    "paused": false
  }
}

Response to maneuver_query. If no active maneuver: { "type": "maneuver_status", "active": false, "maneuver": null }. The paused field indicates whether the maneuver is currently paused. The dock_on_arrival field is a boolean (true/false) for rendezvous maneuvers, null otherwise.

Automation target injection (client-side): When maneuver_status includes target_id and target_type (non-null), the web client automatically adds the target to the multi-target set with role 'automation'. If the player has no focused target, the automation target becomes focused. When a maneuver completes (active=false) or is aborted, all automation-role targets are removed from the target set. On reconnect, maneuver_status is re-queried automatically, so the automation target is re-injected without special logic.

maneuver_aborted

{
  "type": "maneuver_aborted",
  "maneuver_type": "circularize"
}

Sent as immediate acknowledgment when maneuver_abort succeeds. Also published via galaxy:automations stream for offline delivery.

maneuver_paused

{
  "type": "maneuver_paused",
  "maneuver_type": "circularize"
}

Sent as immediate acknowledgment when maneuver_pause succeeds.

maneuver_resumed

{
  "type": "maneuver_resumed",
  "maneuver_type": "circularize"
}

Sent as immediate acknowledgment when maneuver_resume succeeds.

Redis Stream

Tick-engine publishes to galaxy:automations stream:

event: automation.triggered
ship_id: <uuid>
rule_id: <uuid>
rule_name: <string>
tick: <int>
actions_executed: <json-array-string>

Api-gateway subscribes with consumer group api-gateway-automations, forwards to connected player by ship_id lookup.

Error Handling Strategy (#777)

The automation engine uses context-specific exception handling to balance fault isolation with debuggability:

Per-Ship Isolation

The outermost ship evaluation loop catches Exception broadly to prevent one ship’s error from crashing the entire tick loop. This is intentional — a rule parsing error or unexpected state on one ship must not affect other ships’ automation.

Narrowed Exception Types

Within specific operations, exceptions are narrowed to expected types:

Context Caught Exceptions Rationale
Rule JSON parsing json.JSONDecodeError, KeyError, TypeError Malformed rule data
gRPC calls (physics) grpc.aio.AioRpcError Network/service errors
Redis operations OSError, asyncio.TimeoutError, RuntimeError Connection/timeout
Kepler propagation ValueError, ArithmeticError, OverflowError Degenerate orbits

Broad except Exception is only used for the per-ship isolation wrapper, with a logged traceback for debugging.

Error Codes

Code Message Description
E025 Rule limit reached Maximum 10 rules per ship
E026 Invalid automation rule Validation failed (invalid field, operator, action, etc.)
E027 Rule not found Rule does not exist or belongs to different ship
E028 No active maneuver No maneuver is currently running for this ship
E029 Maneuver already paused Maneuver is already in paused state
E030 Maneuver not paused Cannot resume a maneuver that is not paused

Web Client UI

Automation Window

Floatable window opened via View menu or Shift+A shortcut. Same drag/close pattern as Chat window.

On WebSocket connect, the client sends automation_list to load existing rules. This ensures rules persist across page refreshes and reconnections.

Rule List

  • Each rule displays: name, enabled toggle, mode badge, priority, human-readable condition summary, action summary
  • Delete button per rule
  • “Create Rule” button at bottom

Create/Edit Form

  • Name: text input (max 64 chars)
  • Mode: dropdown (Once / Continuous)
  • Priority: number input (0-99)
  • Conditions: dynamic list of (field dropdown, operator dropdown, value input, optional args)
    • Field options: ship.fuel, ship.thrust, ship.speed, ship.distance_to, orbit.apoapsis, orbit.periapsis, orbit.eccentricity, orbit.inclination, orbit.period, orbit.true_anomaly, orbit.angle_to_pe, orbit.angle_to_ap, orbit.angle_to_an, orbit.angle_to_dn, game.tick
    • Operator options: <, >, <=, >=, ==, !=
    • Value: number input
    • Args: body name text input (shown for ship.distance_to and orbit.* fields)
  • Actions: dynamic list of (action dropdown, value input)
    • Action options: set_thrust, set_attitude, circularize, set_inclination, rendezvous, alert
    • Value: number (thrust), dropdown (attitude mode), number (inclination degrees), or text (alert message)
    • circularize requires no value input
    • set_inclination requires target degrees input
    • rendezvous requires a combined target dropdown listing all stations, jumpgates, and other ships (excluding the player’s own ship); the dropdown value format is {type}:{id} (e.g., ship:uuid), parsed on save into target_type and target_id; auto-selects from current target selection if set. The dropdown must use Object.entries(state.shipStates) (not Object.values) since ship state values are keyed by ship_id but do not contain a ship_id property.
    • rendezvous with a dockable target (currently stations) shows a “Dock on arrival” checkbox, checked by default. The checkbox value is sent as dock_on_arrival: true/false in the action object. Hidden for non-dockable targets (ships, jumpgates).
  • Save / Cancel buttons

Maneuver Event History

Maneuver lifecycle events are persisted to PostgreSQL maneuver_events table for post-hoc diagnosis. These are low-volume events (not per-tick) that survive pod restarts.

Recorded Events

Event Type When Extra Fields
maneuver_started Maneuver begins ref_body, target_id
phase_transition Phase changes (rendezvous) from_phase, phase, element_errors
maneuver_completed Maneuver finishes successfully duration_ticks
maneuver_aborted Maneuver aborted by user or system reason, duration_ticks

All writes are wrapped in try/except — database failure must not break maneuver execution.

Retention

1000 rows per ship. Oldest rows beyond this limit are pruned on insert.

Maneuver History

When a maneuver completes or is aborted, a full snapshot of the maneuver state is archived to a Redis list before the maneuver:{ship_id} hash is cleared. This preserves completed maneuver data for later inspection.

Redis Key

maneuver_history:{ship_id} — a Redis list (LPUSH, most recent first).

Snapshot Fields

All fields from the maneuver:{ship_id} hash at the time of completion/abort, plus:

Field Type Description
completed_tick integer Tick when maneuver ended
duration_ticks integer Total ticks elapsed (completed_tick - started_tick)
outcome string "completed" or "aborted"

Retention

Maximum 20 entries per ship. Oldest entries beyond this limit are trimmed on insert (LTRIM).

Diagnostic Tool

kubectl exec -n galaxy-dev deployment/tick-engine -- python /app/tools/maneuver_history.py --ship Demon

Shows table of recent maneuvers with type, strategy, target, duration, outcome, started/completed ticks.

Diagnostic Logging

Per-tick verbose logging to stdout for maneuver debugging. Does not write to Redis or PostgreSQL.

Activation

  • Per-ship: Set Redis key maneuver:{ship_id}:debug to "true" via admin API
  • Global: Set game:maneuver_logging to "verbose" (existing mechanism)
  • Diagnostics emit when either per-ship debug is enabled OR global level is "verbose"

Logger

Dedicated structlog logger src.automation.debug for filtering:

kubectl logs -f deployment/tick-engine -n galaxy-dev | grep maneuver_diag

Output Fields

Field Description
ship_id Ship UUID
tick Current tick
phase Current maneuver phase
da_km Semi-major axis error (km)
de Eccentricity error
di_deg Inclination error (degrees)
draan_deg RAAN error (degrees)
f_rtn RTN thrust direction
eff Effectivity value
thrust Actual thrust applied
angle Alignment angle (degrees)
decision BURN or COAST
distance Distance to target (km, rendezvous only)

Additional per-phase fields: error_m, max_gve, pro_retro (transfer states); phase_deg, mode (phase).

Shuttle Route Action (#906)

Overview

The shuttle action sends a ship back and forth between two stations indefinitely, docking and refueling at each end.

Parameters

Parameter Type Required Description
station_a string yes Station ID for first endpoint
station_b string yes Station ID for second endpoint
strategy string no Rendezvous strategy: efficient, balanced, fastest (default: balanced)
refuel_threshold number no Fuel percentage to depart (default: 100)

State Machine

depart → transit → dock → refuel → depart → transit → dock → refuel → ...
         (to B)    (at B)  (at B)           (to A)    (at A)  (at A)

Phases:

  • depart: Undock from current station (skip if not docked)
  • transit: Rendezvous with target station — delegates to existing rendezvous maneuver phases (plane_change, transfer_plan, brachistochrone, approach, etc.)
  • dock: Auto-dock at arrival station (via dock_on_arrival)
  • refuel: Wait for ship.fuel_pct >= refuel_threshold, then request SERVICE_FUEL

Completion

The shuttle never self-completes. It runs until:

  • The user disables or deletes the automation rule
  • A station is destroyed or unreachable (abort with alert)

Maneuver Fields

Field Description
type shuttle
station_a Station A ID
station_b Station B ID
current_target Which station the ship is heading toward (ID)
strategy Rendezvous strategy
refuel_threshold Fuel % to depart
leg_count Number of completed one-way legs
shuttle_phase Current phase: depart, transit, dock, refuel

Implementation Files

  • services/tick-engine/src/automation_shuttle.py — Shuttle state machine (shuttle_tick function, shuttle intercept in complete_maneuver_shuttle)
  • services/tick-engine/src/automation.py — Registers shuttle action, dispatches to automation_shuttle

Surface Pins (#829)

Overview

Players can place persistent lat/lon markers (“pins”) on body surfaces by clicking in cockpit view. Pins are visible as markers that rotate with the body and can be referenced by the landing automation.

Placement

  1. Player clicks “Place Pin” toggle button (in the cockpit HUD or a toolbar)
  2. Cursor changes to crosshair mode
  3. Player clicks on a body surface (body mesh or terrain)
  4. Raycaster intersects the surface → world position → body-local unit vector → lat/lon
  5. Pin is created with { bodyName, lat, lon, label } and saved to localStorage

Rendering

  • Each pin is a CSS2DObject marker positioned at the body surface
  • Pins rotate with the body (recalculated from lat/lon each frame using body quaternion)
  • Occluded pins (far side of body) are hidden via dot product check
  • Label shows name, lat/lon coordinates

Persistence

  • Stored in settings.surfacePins[] array in localStorage (player-wide)
  • Loaded on cockpit view startup
  • Pins are not per-ship — all ships see the same pins

Automation Integration

  • Land action UI shows a “Use Pin” dropdown listing pins on the current ref body
  • Selecting a pin auto-fills lat/lon fields

Pin Management

  • Toggle mode button to place new pins
  • Click existing pin marker to delete it
  • No drag-to-move (delete + re-place)

Back to top

Galaxy — Kubernetes-based multiplayer space game

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