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:requestsstream minReplicaCount: 0— zero idle costmaxReplicaCount: 4— concurrent planning for multiple ships- Image: same base as game-engine (shares
physics_coreRust 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}
]
}
]
}
tis seconds from schedule start (game time)diris ICRF thrust direction (unit vector)throttleis 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:
- Pre-slew lookahead — when the next burn phase is within
BURN_SLEW_LEAD_Sseconds (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. - Δ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 plannedend_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).
- Generate candidate strategies — each strategy produces an initial guess with a different transfer structure (see below)
- Optimize each candidate — differential evolution tunes burn timing/parameters within each strategy’s framework
- Add correction burns — target-relative correction pairs close residual gaps after the main burns
- 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)
- Validate — check fuel budget, periapsis safety, SOI boundaries
- 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:
- Transfer burn — raise/lower orbit to phasing altitude
- Circularize — match phasing orbit
- Return burn — drop back to target altitude
- 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:
- Compute target position at candidate arrival times (grid search)
- Solve Lambert problem for each candidate → departure Δv + arrival Δv
- Pick candidate that minimizes cost for the given priority
- 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:
- Accelerate toward target for first half of transfer
- Flip and decelerate for second half
- 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_off … b3_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:
- Grid TOF candidates from T_min (based on geometry) to T_max (1 orbit period)
- For each TOF: propagate target forward by TOF, solve Lambert for departure/arrival Δv
- Pick TOF that minimizes priority-weighted cost
- Refine with Nelder-Mead on [TOF, coast_before]
- Add velocity-match correction burn at arrival
Brachistochrone Optimizer
Simplified flip-and-brake model:
- Compute straight-line distance to target (accounting for orbital motion)
- Solve for burn time:
t_burn = sqrt(2 * dist / accel)(each half) - Propagate with Encke to validate
- 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:
- Spec the trajectory format for that maneuver type
- Implement planner logic (reuse existing automation code)
- Implement schedule executor in tick loop
- Test: verify schedule produces same trajectory as reactive at 1x
- Test: verify schedule produces correct trajectory at 100x
- Deploy behind feature flag
- 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
- Client sends
maneuver_queryWebSocket message - API gateway reads
trajectory:{ship_id}from Redis - Responds with
trajectory_schedulemessage containing the schedule phases - Client forward-propagates from the ship’s current position using the schedule’s burn directions and timing to compute a 3D path
- 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 view — flightOverlays.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 view — mapOrbitalPaths.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.