Trajectory Planner Service (#1067)

Problem

The current automation evaluates maneuvers one tick at a time with no lookahead. This causes:

  • Time-scale dependent behavior — per-tick Δv scales with dt, requiring caps and guards at every burn path
  • Synodic stalls — same-altitude phasing detected only after wasting orbits
  • Emergency burn divergence — reactive corrections at high time scale destroy orbits
  • No fuel budgeting — maneuvers discover they can’t complete mid-execution
  • Suboptimal timing — plane changes at guess-and-check nodes, not optimal

Every fix is reactive. The root cause: no forward simulation.

Design

A separate trajectory planner service simulates the full maneuver ahead of time and outputs a time-tagged thrust schedule for the tick loop to execute.

Architecture

Player action
  → API Gateway
  → Redis work queue (stream: trajectory:requests)
  → Trajectory Planner Deployment (autoscaled)
       ↓
  Simulate forward using Encke integrator + gravity model
       ↓
  Thrust schedule: [{t, direction, throttle, phase_label}, ...]
       ↓
  Store in Redis (key: trajectory:{ship_id})
       ↓
  Tick loop reads schedule → interpolates → executes

Components

Component Responsibility
Planner Deployment Persistent pods, autoscaled on queue depth (KEDA). Imports physics_core (Rust Encke), reuses orbital mechanics code.
Request Queue Redis stream trajectory:requests. Entries: {ship_id, target_id, target_type, maneuver_type, priority, constraints}.
Trajectory Store Redis hash trajectory:{ship_id}. Contains the computed thrust schedule + metadata (fuel cost, ETA, phases).
Schedule Executor Replaces evaluate_all_ships in the tick loop. Reads the trajectory, interpolates thrust at current game time, sends to physics.
Deviation Monitor Runs in the tick loop. Compares actual vs planned position. Triggers replan when deviation exceeds threshold.

Pod Lifecycle

  • Kubernetes Deployment with KEDA autoscaler
  • Scale metric: length of trajectory:requests stream
  • minReplicaCount: 0 — zero idle cost
  • maxReplicaCount: 4 — concurrent planning for multiple ships
  • Image: same base as game-engine (shares physics_core Rust module)

Thrust Schedule Format

{
  "ship_id": "uuid",
  "created_tick": 12345,
  "created_game_time": "2026-04-04T12:00:00Z",
  "total_dv": 1057.5,
  "total_fuel": 850.3,
  "phases": [
    {
      "label": "departure_burn",
      "start_t": 0.0,
      "end_t": 48.5,
      "segments": [
        {"t": 0.0, "dir": [0.34, -0.94, 0.0], "throttle": 1.0},
        {"t": 48.5, "dir": [0.34, -0.94, 0.0], "throttle": 0.0}
      ]
    },
    {
      "label": "coast",
      "start_t": 48.5,
      "end_t": 4800.0,
      "segments": []
    },
    {
      "label": "arrival_burn",
      "start_t": 4800.0,
      "end_t": 4850.2,
      "segments": [
        {"t": 4800.0, "dir": [-0.34, 0.94, 0.0], "throttle": 1.0},
        {"t": 4850.2, "dir": [-0.34, 0.94, 0.0], "throttle": 0.0}
      ]
    }
  ]
}
  • t is seconds from schedule start (game time)
  • dir is ICRF thrust direction (unit vector)
  • throttle is 0.0–1.0
  • Between segments: linear interpolation of direction, constant throttle
  • Empty segments = coast (zero thrust)

Schedule Executor (Tick Loop)

Replaces the current evaluate_all_ships → _evaluate_ship → _execute_maneuver_tick chain for ships with active trajectories.

The executor honours two principles that make planned burns actually fire:

  1. Pre-slew lookahead — when the next burn phase is within BURN_SLEW_LEAD_S seconds (default 10 s), the executor commands the burn’s attitude mode immediately, with throttle 0. The PD attitude controller (ω_n = 0.5 rad/s, ζ = 1.0, settling time ~8 s) needs lead time to slew into alignment. Without this, sub-10 s burns end before the ship is pointed correctly and never deliver any Δv.
  2. Δv-driven completion — the planned Δv for each burn is the source of truth, not the planned end time. The executor tracks cumulative Δv applied per burn (Δv_applied = Σ thrust_accel · sub_dt) and continues firing until Δv_applied ≥ Δv_planned, even if that runs past the planned end_t. The next correction burn absorbs any residual timing slip.
async def execute_trajectory(ship_id, game_time, trajectory, maneuver):
    """Execute pre-computed trajectory at current game time."""
    t_elapsed = (game_time - trajectory.start_time).total_seconds()
    phase, segment = trajectory.interpolate(t_elapsed)

    # ── Pre-slew lookahead ────────────────────────────────────────────
    # If the next burn is within BURN_SLEW_LEAD_S, command its attitude
    # mode now (throttle 0) so the PD controller settles in time.
    if segment is None or segment.throttle == 0:
        upcoming = trajectory.next_burn_within(
            t_elapsed, BURN_SLEW_LEAD_S)
        if upcoming is not None:
            await physics_stub.SetSteeringCommand(
                ship_id=ship_id,
                attitude_mode=upcoming.mode,
                direction=upcoming.dir,
                thrust_level=0.0)
            return

    # ── Δv-driven burn completion ─────────────────────────────────────
    # While a burn is pending, fire until Δv_applied ≥ Δv_planned.
    # Track per-ship state in the maneuver dict.
    burn_state = maneuver.get("burn_state")
    if segment is not None and segment.throttle > 0 and burn_state is None:
        # Entering a new burn — record the planned Δv.
        accel = ship.max_thrust / ship.mass
        dv_planned = accel * (segment.end_t - segment.start_t)
        burn_state = {
            "phase_label": phase.label,
            "dv_planned": dv_planned,
            "dv_applied": 0.0,
            "mode": segment.mode,
            "dir": segment.dir,
        }
        maneuver["burn_state"] = burn_state

    if burn_state is not None:
        # Accumulate applied Δv from last tick's actual thrust.
        accel_now = ship.max_thrust / ship.mass
        burn_state["dv_applied"] += accel_now * tick_dt
        if burn_state["dv_applied"] >= burn_state["dv_planned"]:
            # Burn complete — clear state, advance.
            maneuver["burn_state"] = None
            await physics_stub.SetSteeringCommand(
                ship_id=ship_id, thrust_level=0.0)
        else:
            # Keep firing — even past the planned end_t.
            await physics_stub.SetSteeringCommand(
                ship_id=ship_id,
                attitude_mode=burn_state["mode"],
                direction=burn_state["dir"],
                thrust_level=1.0)
        return

    # Coast
    await physics_stub.SetSteeringCommand(
        ship_id=ship_id, thrust_level=0.0)

Time-scale invariant by construction: the schedule specifies thrust at absolute game times. The tick loop just looks up “what should thrust be at time T?” regardless of how many game-seconds elapsed per tick.

Schedule clock anchoring (#1087)

schedule.start_game_time is set by the planner to publish time + a safety margin (PUBLISH_OFFSET_S, default 30 s of game time), not to the request submission time. Without the margin, schedules computed by a slow planner would arrive at the game-engine with t_elapsed > 0, potentially past early-scheduled burns and inside the pre-slew window of the first burn.

Concretely, when the consumer publishes a schedule:

current_game_time = await get_current_game_time(redis)
deferred_start = current_game_time + timedelta(seconds=PUBLISH_OFFSET_S)
schedule.start_game_time = max(request.game_time, deferred_start.isoformat())

This guarantees the schedule executor sees a small negative (or zero) t_elapsed on its first read, so the full pre-slew window is available before any planned burn can fire.

Burn execution constants

Constant Value Unit Rationale
BURN_SLEW_LEAD_REAL_S 10.0 wall-clock seconds Attitude PD controller settling time is ~8 s of wall-clock; 10 s gives 25% margin. Multiplied by current time_scale at use so the wall-clock pre-slew window stays constant regardless of how fast the game runs (#1087).
BURN_DV_TOLERANCE 0.5 m/s When Δv_applied ≥ Δv_planned − tolerance, declare the burn complete. Avoids spinning on float-precision residuals.
BURN_OVERRUN_LIMIT_REAL_S 30.0 wall-clock seconds Hard cap on how far past end_t a burn may extend. Multiplied by current time_scale at use for the same reason. Beyond this, log a warning and stop — the planner’s assumed mass/thrust were wrong enough that the next correction burn should clean it up.
PUBLISH_OFFSET_S 30.0 game seconds Margin added to publish time when computing the deferred start_game_time. Covers planner compute jitter + game-engine pickup latency.

The pre-slew lookahead computes slew_lead_game_s = BURN_SLEW_LEAD_REAL_S * time_scale, floored at half the time since the previous burn’s end so the lookahead can never overlap a previous burn at very high time scales. The same scaling is applied to BURN_OVERRUN_LIMIT_REAL_S.

Deviation Monitor

Runs every N ticks (e.g., every 10). Compares actual ship position to the planned trajectory position at the current time.

deviation = |actual_pos - planned_pos(t)|
if deviation > threshold:
    request_replan(ship_id, reason="position_deviation")

Thresholds (distance from planned trajectory):

  • Rendezvous transfer: 1% of transfer distance
  • Approach/dock: 100m
  • Landing descent: 50m

Replan triggers:

  • Position deviation exceeds threshold
  • Player changes target or cancels
  • Fuel consumption differs >5% from plan (damage, docking refuel)
  • Ship exits planned SOI

Planner Internals (Phase 1: Rendezvous)

The planner reuses existing orbital mechanics code but runs it forward in simulation time instead of one tick at a time.

Input

@dataclass
class TrajectoryRequest:
    ship_id: str
    ship_pos: Vec3          # ICRF at request time
    ship_vel: Vec3
    ship_class: str
    fuel: float
    target_id: str
    target_type: str        # "station", "ship", "body"
    target_pos: Vec3
    target_vel: Vec3
    ref_body: str           # e.g., "Callisto"
    body_states: dict       # all body positions/velocities at request time
    priority: str           # "efficient", "balanced", "fastest"
    constraints: dict       # max_fuel, max_time, etc.

Planning Algorithm

The planner evaluates multiple transfer strategies in parallel and selects the best result based on priority (efficient / balanced / fastest).

  1. Generate candidate strategies — each strategy produces an initial guess with a different transfer structure (see below)
  2. Optimize each candidate — differential evolution tunes burn timing/parameters within each strategy’s framework
  3. Add correction burns — target-relative correction pairs close residual gaps after the main burns
  4. Select best — pick the strategy that best matches the priority:
    • fastest → minimize transfer time (accept higher Δv)
    • efficient → minimize Δv (accept longer transfer)
    • balanced → weighted combination (current default)
  5. Validate — check fuel budget, periapsis safety, SOI boundaries
  6. Output — thrust schedule + metadata

Transfer Strategies

The planner maintains a registry of strategy generators. Each produces an initial burn skeleton with different structure and trade-offs.

Strategy 1: Hohmann Phasing (fuel-efficient, slow)

Classic 4-burn phasing maneuver for same-SOI rendezvous:

  1. Transfer burn — raise/lower orbit to phasing altitude
  2. Circularize — match phasing orbit
  3. Return burn — drop back to target altitude
  4. Circularize — match target orbit

Best for: large phase angle gaps where drift is efficient. Dominates when priority: efficient.

Limitation: phasing coast duration scales with gap / Δn where Δn is the differential mean motion. For small altitude differences, coast can be 10+ hours even for targets only tens of km away.

Strategy 2: Direct Intercept (fast, higher Δv)

Lambert-solver or iterative intercept for short time-of-flight transfers:

  1. Compute target position at candidate arrival times (grid search)
  2. Solve Lambert problem for each candidate → departure Δv + arrival Δv
  3. Pick candidate that minimizes cost for the given priority
  4. Build 2-burn schedule: departure burn + arrival/matching burn

Best for: targets within ~1 orbit distance where a direct burn is faster than phasing. Dominates when priority: fastest and phase angle is small.

Key advantage: transfer time is bounded by time-of-flight, not drift rate. A 30-minute direct transfer at ~500 m/s Δv beats a 10-hour phasing coast at ~400 m/s Δv when the player wants speed.

Strategy 3: Brachistochrone (continuous thrust)

Constant-thrust transfer with flip-and-brake midpoint:

  1. Accelerate toward target for first half of transfer
  2. Flip and decelerate for second half
  3. Arrival with zero relative velocity

Best for: high-thrust ships on medium-distance transfers. Natural fit for priority: fastest when fuel budget allows continuous burn.

Multi-Strategy Selection

async def plan_rendezvous(request: TrajectoryRequest) -> ThrustSchedule:
    candidates = []

    # Generate and optimize each strategy in parallel
    hohmann = optimize_hohmann(request)
    direct = optimize_direct_intercept(request)
    brach = optimize_brachistochrone(request)

    for result in [hohmann, direct, brach]:
        if result and result.fuel <= request.fuel:
            candidates.append(result)

    # Select based on priority
    if request.priority == "fastest":
        return min(candidates, key=lambda r: r.transfer_time)
    elif request.priority == "efficient":
        return min(candidates, key=lambda r: r.total_dv)
    else:  # balanced
        return min(candidates, key=lambda r: r.transfer_time + r.total_dv * 10)

Optimizer Details

Hohmann Optimizer: Two-Stage with Correction Burns

Stage 1: Hohmann Timing (5D Differential Evolution)

Optimizes the 4-burn Hohmann phasing maneuver timing:

Parameter Description Bounds
coast_shift Shift of burns 2+3 (phasing coast duration) constrained to keep burns within 80% of horizon
b0_offb3_off Start time offset for each of 4 burns ±T_orbit/2

Settings: popsize=20, maxiter=150, tol=0.001, init='sobol', polish=True.

This typically converges to ~200-300 km. The residual comes from the nonlinear interactions between burn timing, target motion, and the limited resolution of the cost function landscape for phasing maneuvers.

Stage 2: Correction Burns (8D Differential Evolution + Nelder-Mead)

Adds 2 pairs of correction burns after the 4 Hohmann burns:

Burn Mode Purpose
Position correction 1 toward_target (mode=2) Burn toward target position to close distance gap
Velocity correction 1 velocity_match (mode=3) Burn to match target velocity
Position correction 2 toward_target (mode=2) Fine position correction
Velocity correction 2 velocity_match (mode=3) Final velocity matching

Each correction pair has 4 optimizer parameters: coast-before, burn duration, gap between burns, velocity-match duration. Total: 8D.

The toward_target and velocity_match burn modes are new additions to the Rust simulate_trajectory function. At each integration timestep, they compute the thrust direction from the current ship-target relative state.

After DE, Nelder-Mead polishes the 8 correction parameters. Typically brings the solution from ~100 km to < 50 km.

Direct Intercept Optimizer

Uses Lambert solver with time-of-flight grid search:

  1. Grid TOF candidates from T_min (based on geometry) to T_max (1 orbit period)
  2. For each TOF: propagate target forward by TOF, solve Lambert for departure/arrival Δv
  3. Pick TOF that minimizes priority-weighted cost
  4. Refine with Nelder-Mead on [TOF, coast_before]
  5. Add velocity-match correction burn at arrival

Brachistochrone Optimizer

Simplified flip-and-brake model:

  1. Compute straight-line distance to target (accounting for orbital motion)
  2. Solve for burn time: t_burn = sqrt(2 * dist / accel) (each half)
  3. Propagate with Encke to validate
  4. Adjust for gravity losses via short DE optimization on [burn_duration, flip_time]

Schedule Execution

The schedule executor resolves toward_target and velocity_match modes at runtime by looking up the target’s current position/velocity from Redis and computing the ICRF direction on each tick.

Convergence Threshold

The planner rejects trajectories with final distance > 50 km.

Priority-aware cost functions:

Priority Cost Function Rationale
fastest transfer_time + dist × 10 Minimize time; heavily penalize miss distance
efficient total_dv × 100 + dist Minimize fuel; accept longer transfers
balanced dist + rv × 1000 Current default — minimize miss + velocity error

Internal Simulation Loop

dt_plan = 1.0  # always 1-second steps regardless of game time scale
t = 0.0
while t < max_planning_horizon:
    # Propagate bodies (Keplerian)
    body_states_t = propagate_bodies_kepler(body_states_0, t)

    # Compute maneuver decision (reuses existing automation logic)
    thrust_dir, throttle = evaluate_maneuver_at(ship_state, body_states_t, target_state_t)

    # Record to schedule
    if throttle > 0:
        schedule.add_segment(t, thrust_dir, throttle)

    # Integrate ship forward
    ship_state = encke_step(ship_state, ref_body, body_states_t, thrust_dir * throttle, dt_plan)

    t += dt_plan

Migration Strategy

Gradual, maneuver-type by maneuver-type:

Phase Maneuver Types Reactive Fallback
Phase 1 Rendezvous (transfer + phasing + approach + dock) Yes — if planner unavailable, fall back to current reactive automation
Phase 2 Landing (deorbit + braking + descent + touchdown) Yes
Phase 3 Ascent, orbit changes, station-keeping Yes
Phase 4 Remove reactive automation (or keep as emergency fallback) Optional

Each phase:

  1. Spec the trajectory format for that maneuver type
  2. Implement planner logic (reuse existing automation code)
  3. Implement schedule executor in tick loop
  4. Test: verify schedule produces same trajectory as reactive at 1x
  5. Test: verify schedule produces correct trajectory at 100x
  6. Deploy behind feature flag
  7. Remove reactive path (or keep as fallback)

Kubernetes Resources

apiVersion: apps/v1
kind: Deployment
metadata:
  name: trajectory-planner
spec:
  replicas: 0  # KEDA manages scaling
  template:
    spec:
      containers:
      - name: planner
        image: galaxy-trajectory-planner:latest
        resources:
          requests:
            cpu: 500m
            memory: 256Mi
          limits:
            cpu: "2"
            memory: 512Mi
---
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: trajectory-planner-scaler
spec:
  scaleTargetRef:
    name: trajectory-planner
  minReplicaCount: 0
  maxReplicaCount: 4
  triggers:
  - type: redis-streams
    metadata:
      stream: trajectory:requests
      consumerGroup: planner
      lagCount: "1"

Trajectory Visualization (#1068)

The client displays the planned trajectory as a projected path in the cockpit and map views, showing the player where their ship will go.

Data Flow

  1. Client sends maneuver_query WebSocket message
  2. API gateway reads trajectory:{ship_id} from Redis
  3. Responds with trajectory_schedule message containing the schedule phases
  4. Client forward-propagates from the ship’s current position using the schedule’s burn directions and timing to compute a 3D path
  5. Renders as a colored THREE.Line in the cockpit/map scenes

WebSocket Message

Included in the maneuver_status response when a trajectory schedule exists:

{
  "type": "maneuver_status",
  "active": true,
  "maneuver": { ... },
  "trajectory": {
    "start_game_time": "2026-04-04T12:00:00Z",
    "total_dv": 1057.5,
    "total_fuel": 850.3,
    "phases": [
      {
        "label": "pre_intercept_coast",
        "start_t": 0.0,
        "end_t": 3300.0,
        "burn": false
      },
      {
        "label": "intercept_burn",
        "start_t": 3300.0,
        "end_t": 3480.0,
        "burn": true,
        "mode": "icrf:0.34,-0.94,0.0"
      }
    ]
  }
}

Phases are simplified for the client: burn flag + optional mode/direction. The client doesn’t need segment-level detail — just when burns happen and in what direction.

Client Rendering

Cockpit viewflightOverlays.js:

  • New updateTrajectoryPath() function
  • Forward-propagates ship position using Kepler (coast) or Kepler + thrust (burn) for each phase
  • Renders as THREE.Line with phase-based coloring:
    • Coast: dim gray (0x666666), opacity 0.3
    • Burn: bright orange (0xff8800), opacity 0.8
  • Uses same pivot-relative frame as existing orbital path
  • Shows from current time to end of schedule

Map viewmapOrbitalPaths.js:

  • Same propagation logic, rendered in the map coordinate frame
  • Color matches cockpit scheme

Propagation

Client-side forward propagation uses simplified Kepler (no perturbations):

function propagateTrajectory(pos, vel, refBody, mu, phases, gameTime, startTime) {
  const points = [];
  let r = pos, v = vel;
  const tElapsed = (gameTime - startTime) / 1000;  // seconds

  for (const phase of phases) {
    if (phase.end_t < tElapsed) continue;  // skip past phases
    const tStart = Math.max(phase.start_t, tElapsed);
    const dt = 60;  // 60-second steps for rendering

    for (let t = tStart; t < phase.end_t; t += dt) {
      points.push({...r});
      if (phase.burn && phase.mode) {
        // Apply thrust in burn direction
        const accel = thrustAccel * burnDirection;
        v = add(v, scale(accel, dt));
      }
      // Kepler step
      [r, v] = keplerStep(r, v, mu, dt);
    }
  }
  return points;
}

Known Limitations (Current Implementation)

All three strategies (Hohmann, Lambert, brachistochrone) are implemented. N-body shooting refinement, periapsis safety, priority-aware cost functions, and extended TOF search are operational.

Remaining limitations:

  • Two-body initial guesses in perturbed regions (mitigated by N-body refinement)
  • Fixed burn count per strategy (not free optimal control)
  • Lambert only viable for phase angles < 120° (Hohmann covers larger angles)
  • TOF grid covers 2–200% of orbital period (sufficient for same-SOI)

Open Questions

  • Body propagation accuracy: Keplerian propagation for planning horizon (hours to days). Is this accurate enough for moon-orbiting scenarios where tidal perturbations matter? May need simplified N-body for long horizons.
  • Schedule size: A 24-hour rendezvous at 1-second resolution = 86,400 entries. Compress by storing only burn start/stop with direction? Phase-based format keeps it compact.
  • Concurrent replans: If deviation triggers a replan while a plan is in progress, cancel the old one? Queue? Latest-wins?
  • Player feedback: Show planned trajectory on the client (orbit preview)? This requires sending the schedule to the web client.

Back to top

Galaxy — Kubernetes-based multiplayer space game

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