Landing Mechanics
Overview
Ships can land on celestial bodies with terrain (currently Luna). Landing replaces the collision/destruction check with a safe-touchdown test. Landed ships attach to the body surface, rotating with it, and can launch by applying thrust.
Landing Conditions
When a ship contacts the surface (distance from body center < surface radius), the physics checks two conditions:
| Condition | Threshold | Derivation |
|---|---|---|
| Vertical speed | < landing_max_vertical_speed (default 10 m/s) |
Generous limit (Apollo was ~1-3 m/s) |
| Tilt from vertical | < landing_max_tilt_deg (default 37°) |
CoM inside landing leg footprint: arctan(leg_radius / CoM_height) |
Vertical speed is the radial component of the ship’s velocity relative to the body:
r_hat = normalize(ship_position - body_position)
v_rel = ship_velocity - body_velocity
v_radial = dot(v_rel, r_hat)
vertical_speed = abs(v_radial) # positive = outward, negative = inward
Tilt angle is the angle between the ship’s thrust axis (+Z in body frame) and the local vertical (radial direction from body center):
ship_up = rotate(quaternion, [0, 0, 1]) # ship +Z in ICRF
r_hat = normalize(ship_position - body_position)
cos_tilt = dot(ship_up, r_hat)
tilt_angle = acos(clamp(cos_tilt, -1, 1))
Outcomes
| Vertical Speed | Tilt | Result |
|---|---|---|
| < threshold | < threshold | Safe landing |
| < threshold | >= threshold | Crash (toppled) |
| >= threshold | any | Crash (too fast) |
All ship classes can land, not just the planetary lander (though most lack the TWR to hover on large bodies).
Landed State
On successful landing:
- Set
landed_body_nameto the body name - Store surface-relative position as a unit normal (direction from body center) and altitude offset
- Zero the ship’s velocity and angular velocity
- Snap attitude to upright (align ship +Z with local vertical)
- Publish
ship.landedevent
Surface Attachment
Each tick, a landed ship’s position and velocity are computed from the body’s state:
# Surface normal stored at landing time (body-local, does not change)
# Transform to ICRF using body rotation at current game time
surface_normal_icrf = body_rotation(game_time) * landed_surface_normal
# Position: on surface at stored normal direction
# ground_contact_offset raises center of mass so ship feet touch surface
surface_r = body.radius + terrain_elevation(landed_surface_normal) + ship_class.ground_contact_offset
ship_position = body.position + surface_normal_icrf * surface_r
# Velocity: match body surface velocity (body orbital velocity + rotation)
ship_velocity = body.velocity + cross(body_angular_velocity, surface_normal_icrf * surface_r)
# Attitude: +Z aligned with surface normal (upright)
ship_attitude = quaternion_aligning_z_to(surface_normal_icrf)
Fuel Consumption While Landed
- Engines off (thrust_level = 0, no RCS input): zero fuel consumption
- Engines on: normal fuel consumption rules apply (main engine, RCS rotation, RCS translation)
- Reaction wheels: no fuel cost (same as always)
Ship State Fields
New fields on Ship model:
| Field | Type | Default | Description |
|---|---|---|---|
landed_body_name |
string | ”” | Body name when landed, empty when flying |
landed_surface_normal_x |
float | 0 | Body-local surface normal X |
landed_surface_normal_y |
float | 0 | Body-local surface normal Y |
landed_surface_normal_z |
float | 0 | Body-local surface normal Z |
These fields are persisted in Redis and included in the protobuf ShipState message.
Launch
A landed ship launches (detaches from surface) when the combined upward force (main engine + RCS +Z translation) exceeds gravity. RCS +Z is included because on micro-gravity bodies (e.g. Phobos, g ≈ 0.004 m/s²) the main engine alone has TWR > 2000 and cannot be used for controlled liftoff — RCS is the only viable option.
# Main engine + RCS upward thrust
thrust_force = max_thrust * thrust_level
rcs_lift = rcs_linear_thrust * max(translation_input.z, 0)
total_lift = thrust_force + rcs_lift
# Local gravity
local_g = G * body.mass / surface_r^2
weight = ship_mass * local_g
# Launch when net force exceeds weight
if total_lift > weight:
clear landed_body_name
set velocity to body surface velocity + small upward kick
publish ship.launched event
The “small upward kick” is one tick’s worth of net acceleration to prevent immediate re-contact:
net_accel = (upward_force - weight) / ship_mass
kick_velocity = net_accel * dt
ship_velocity += surface_normal * kick_velocity
Events
ship.landed
Published to galaxy:ships Redis stream on successful landing.
event: ship.landed
ship_id: <uuid>
player_id: <uuid>
body: <string> # Body name (e.g., "Luna")
ship.launched
Published to galaxy:ships Redis stream on launch from surface.
event: ship.launched
ship_id: <uuid>
player_id: <uuid>
body: <string> # Body name launched from
Client Display
- Fleet HUD shows “Landed” status (similar to “Docked”)
- Ship mesh is positioned on surface, oriented upright
- Orbit diagram shows “LANDED” instead of orbital elements
- AGL reads 0 when landed (AGL is measured from ship’s lowest contact point using
ground_contact_offset, not center of mass)
Landed Ship Rendering Synchronization
Landed ships must rotate in exact synchronization with the terrain to avoid visual jitter:
- Ship attitude: Derived from
bodyMesh.quaternion * landedLocalAttitude(captured once at landing) instead of SLERP between discrete server attitude updates. SLERP creates inter-tick desynchronization where the ship model rotates at a different rate than the terrain. - Body mesh position: Computed directly as
-Q * landedLocalOffset(Three.js space) instead of via ICRF coordinate subtraction, avoiding Float64 precision loss from large-number cancellation. - Camera local-up: Computed directly from
Q * landedLocalOffsetdirection instead of ICRF position subtraction (see camera-local-up spec).
Surface State Helpers
The physics simulation uses three internal helper methods to avoid duplicating surface-pinning logic across the _update_ship_landed, _update_ship_contact, and _update_ship_tipped methods:
_snap_ship_to_surface(ship, body, nx, ny, nz, contact_r)
Pins the ship’s ICRF position to the surface contact point:
ship.position = body.position + (nx, ny, nz) * contact_r
_set_surface_velocity(ship, body, nx, ny, nz, contact_r, body_omega_vec)
Sets the ship’s velocity to the co-rotating body surface velocity:
r_vec = (nx, ny, nz) * contact_r
v_rot = cross(body_omega_vec, r_vec)
ship.velocity = body.velocity + v_rot
_check_launch(ship, cls, local_g) -> bool
Returns True when the ship’s upward force exceeds local gravity:
thrust_force = max_thrust * thrust_level
rcs_lift = rcs_linear_thrust * max(translation_input.z, 0)
total_lift = thrust_force + rcs_lift
weight = ship_mass * local_g
return total_lift > weight
Note: _update_ship_tipped uses only main engine thrust for its launch check (no RCS), since a tipped ship’s +Z axis is not aligned with the surface normal.
Implementation Files
Physics Service
services/physics/src/models.py— Add landed fields to Shipservices/physics/src/simulation.py— Landing/launch logic in_update_ship()services/physics/src/redis_state.py— Serialize/deserialize landed fieldsservices/physics/proto/physics.proto— Add landed fields to ShipState
Web Client
services/web-client/src/fleetHud.js— “Landed” status displayservices/web-client/src/orbitDiagram.js— “LANDED” display when on surface
Tick Engine
services/tick-engine/src/simulation.py— Forward landed fields in tick data
AGL Computation
AGL (Altitude Above Ground Level) is measured from the ship’s lowest physical contact point (feet/engine bells) to the terrain surface, not from the center of mass. This uses the ship class’s ground_contact_offset parameter:
agl = distance_to_body_center - body_radius - terrain_elevation - ground_contact_offset
When terrain elevation data is available (e.g., Luna with LOLA heightmap), terrain_elevation is sampled from the heightmap at the ship’s surface normal. Otherwise, the body’s mean radius is used. AGL is clamped to >= 0.
This ensures AGL reads exactly 0 when the ship is landed with feet touching the surface, rather than showing the offset between center of mass and feet.
Configuration
Landing thresholds and spawn offset are configurable in services/physics/src/config.py via environment variables:
| Setting | Default | Env Var | Description |
|---|---|---|---|
landing_max_vertical_speed |
10.0 | LANDING_MAX_VERTICAL_SPEED |
Max vertical speed for safe landing (m/s) |
landing_max_tilt_deg |
37.0 | LANDING_MAX_TILT_DEG |
Max tilt from vertical for safe landing (degrees) |
spawn_offset_distance |
1000.0 | SPAWN_OFFSET_DISTANCE |
Prograde offset when spawning near station/body (m) |
Landing Autopilot
The landing autopilot (maneuver_landing.py) executes a multi-phase descent sequence. When a target latitude/longitude is specified, the full 7-phase sequence runs. Without coordinates, the original 5-phase sequence starts at deorbit (backward compatible).
Phases
- Plane change (targeted only) — Adjust orbital inclination/RAAN so ground track passes through target latitude
- Coast to deorbit (targeted only) — Wait until ground track is about to pass over target longitude, then trigger deorbit. Retrograde attitude for pre-alignment.
- Deorbit — Retrograde burn to lower periapsis to
DEORBIT_TARGET_ALT(2 km AGL), biased toward target for crossrange correction - Braking — Surface-retrograde attitude to kill horizontal velocity, with lateral correction toward target (faded at low h_speed to prevent direction oscillation)
- Vertical descent — PID-controlled descent rate scaled by altitude, with lateral correction
- Hover steer (targeted, ~500m AGL) — Hover at constant altitude while RCS closes lateral gap to pad. Transitions directly to terminal when within 10m of target and sets
hover_steer_done=truehysteresis flag. Braking and vertical_descent only enter hover_steer if the flag is not set, preventing re-entry oscillation after hover_steer has completed. - Terminal (< 500m AGL) — Gentle sqrt profile with lateral correction. Fallback attitude is LOCAL_VERTICAL for targeted landings.
- Touchdown (< 10m AGL) — Hold at 95% hover thrust until surface contact
Targeted Landing
When target_lat and target_lon (decimal degrees) are specified in the land action, the autopilot performs a precision landing at those coordinates.
Accuracy Requirement
Targeted landings must touch down within 10 meters of the specified coordinates. This is achieved through:
- Relaxed plane change tolerances (1.5° inclination, 5° RAAN) — deorbit corrects residual crossrange
- Tight coast-to-deorbit longitude tolerance (0.5°)
- RCS lateral correction during all descent phases (braking, vertical descent, terminal)
- Continuous target ICRF recomputation each tick to track body rotation
Coordinate Conversion
Target lat/lon are converted to a body-local unit normal at maneuver start:
lat, lon = radians(target_lat), radians(target_lon)
target_normal = (cos(lat)*sin(lon), sin(lat), cos(lat)*cos(lon))
Body-local frame: Y = north pole (spin axis), Z = prime meridian, X = 90° E.
To convert body-local normal to ICRF at a given game time, the body rotation quaternion is applied:
- Build body quaternion from spin axis, rotation period, prime meridian W₀, and J2000 seconds
- Rotate body-local normal by body quaternion to get ICRF direction
Phase: plane_change
Goal: Adjust orbital plane so the ground track passes through the target latitude.
Algorithm:
- Compute current orbital elements via
keplerian_from_cartesian() -
Required inclination ≥ target_lat in body-equatorial frame - Compute required RAAN from target ICRF direction — computed once at phase entry and stored in maneuver dict (
plane_change_raan). Recomputing every tick causes the target to oscillate between two solutions as the body rotates. - Sequential steering: inclination first via
oop_steering(), then RAAN viaorbit_normal_steering() - Apply with
ATTITUDE_DIRECTIONmode + cosine-scaled thrust + intermediate direction clamping (same proven pattern as orbit_match Q-law) - Transition to
coast_to_deorbitwhen both inclination and RAAN converge (raw error, not sin(i)-weighted)
Phase: coast_to_deorbit
Goal: Wait until the ship’s ground track is about to pass over the target longitude.
Attitude: Retrograde (pre-aligned for the deorbit burn, which is retrograde). This eliminates attitude settling delay when the deorbit burn begins.
Algorithm:
- Each tick, convert ship position to body-local → extract sub-satellite latitude and longitude
- Estimate total descent time (Hohmann transfer + braking + vertical descent)
- Compute deorbit lead angle: body rotation during descent minus orbital ground track advance
- Crosstrack budget check: When near the deorbit window (within a longitude tolerance derived from the crosstrack budget), compute crosstrack distance as
body_radius × |target_lat − ship_lat|(latitude difference only, not great-circle distance — downrange is handled by the lead angle). If crosstrack < budget, trigger deorbit. - Fallback: When sub-satellite longitude is within 0.5° of the deorbit window AND latitude is within 5° of target: transition to
deorbit. Both checks handle wrap-around (lon_diff > 360 − tolerance). The latitude check prevents firing at the wrong orbital crossing — for inclined orbits, the ship crosses the target longitude twice per orbit at different latitudes.
Descent time estimate:
t_hohmann = π × √(a_transfer³/μ)
t_braking = v_pe / thrust_accel
t_vertical = √(2h/g) × 2
t_total = t_hohmann + t_braking + t_vertical
Ship coasts with zero thrust, prograde attitude during this phase.
Lateral Correction During Descent
During braking, vertical descent, and terminal phases, the autopilot steers toward the target coordinates:
- Compute target ICRF position from body-local normal + body rotation at current game time
- Compute lateral offset: project
(ship_pos - target_pos)perpendicular to radial direction - Compute desired horizontal velocity toward target (PD controller scaled by altitude):
closing_speed = min(max_speed, lateral_dist × Kp)- Kp interpolates between
LATERAL_KP_LOW(0.15 near surface) andLATERAL_KP_HIGH(0.02 at altitude) overLATERAL_ALT_SCALE(5000m) - Max speed interpolates between 5 m/s (surface) and 200 m/s (altitude)
- No deadband (#920): lateral correction is computed at all distances, even sub-meter. The
_compute_lateral_correctionfunction must never return zero corrections based on distance alone — this would cause residual lateral drift that accumulates during the hundreds of meters of terminal descent.
- Feed velocity error into
_horizontal_to_body_rcs()with settling time of 5 ticks (scale = 1/(rcs_accel × 5.0 × dt))
During braking: bias surface-retrograde attitude toward target. During vertical descent/terminal: main engine tilt + RCS lateral jets steer toward target.
Main engine tilt (vertical descent & terminal, targeted landings only): The main engine direction is tilted away from local-vertical toward the target by an angle proportional to the actual lateral distance to the target (not the velocity correction magnitude):
tilt = min(MAX_TILT, lateral_distance / max(AGL × 0.3, 1.0))
The tilt direction follows the velocity correction vector from _compute_lateral_correction.
This attitude is maintained even during the coast phase (thrust=0) so that the ship is pre-aligned when braking begins.
Terminal fallback attitude (#920): When tilt correction is not applied during terminal phase (e.g., very small lateral offset), the fallback attitude is LOCAL_VERTICAL for targeted landings, not SURFACE_RETROGRADE. Retrograde attitude kills horizontal velocity indiscriminately, which can push the ship away from target. Local vertical holds position and lets RCS handle final lateral corrections.
UI
The automation dialog adds two optional number inputs for land actions:
- Latitude (-90 to 90, decimal degrees)
- Longitude (-180 to 180, decimal degrees)
Action summary format: land(Mars 18.7/-133.8) when coordinates specified.
Data Flow
automation.pyextractstarget_lat,target_lonfrom action_start_maneuver()stores them in maneuver hash, pre-computes body-local normal (target_nx/ny/nz)- Initial phase set to
plane_change(instead ofdeorbit) maneuver_land_tick()reads target data and dispatches to phase handlers
Client Landing Target Marker
When a targeted landing maneuver is active, the web client renders a surface marker at the target coordinates:
maneuver_statusresponse includestarget_lat,target_lon,target_bodyfields- Client creates a CSS2D marker pinned to the body surface at the target position
- Each frame, the marker position is recomputed from lat/lon using the body’s current rotation quaternion:
- Convert lat/lon to body-local unit normal:
(cos(lat)*sin(lon), sin(lat), cos(lat)*cos(lon)) - Transform to ICRF by rotating through the body’s spin quaternion
- Scale by body radius and apply COCKPIT_SCALE
- Position relative to the body mesh center
- Convert lat/lon to body-local unit normal:
- Marker is removed when the maneuver completes or is aborted
Braking Phase
The braking phase kills horizontal velocity while maintaining vertical safety. The ship points surface-retrograde, so the engine vector has both horizontal and vertical components depending on the flight path angle.
Throttle floor: The throttle is always at least hover_thrust (the fraction needed to counteract local gravity). This prevents the scenario where a proportional-only throttle based on horizontal speed falls below gravity and the ship accelerates downward uncontrollably — which would happen on bodies with significant gravity (Mars g=3.7 m/s², Earth g=9.8 m/s²) when horizontal speed is low but non-zero.
thrust = max(hover_thrust, min(1.0, h_speed / 50.0))
Descent rate safety check: The autopilot computes a safe descent rate ceiling based on current altitude and the available deceleration margin above hover:
thrust_accel = max_thrust / mass
decel_margin = thrust_accel - g_local # available decel above hover
safe_rate = sqrt(2 * decel_margin * AGL)
If the actual descent rate exceeds safe_rate, the braking phase transitions immediately to vertical descent, even if horizontal speed has not been fully killed. The vertical descent PID controller then manages both descent rate and residual horizontal drift.
Altitude-scaled braking exit for targeted landings: During targeted landings, lateral correction toward the pad can prevent horizontal speed from ever reaching the base threshold (5 m/s), because the controller intentionally maintains lateral velocity to close the gap to the target. This can trap the ship in braking at low altitude until the hard descent-rate safety limit is hit — leaving insufficient margin for vertical descent to arrest the fall.
To prevent this, the braking exit threshold scales with altitude for targeted landings:
alt_km = min(AGL / 1000, 20)
effective_threshold = max(BRAKING_COMPLETE_HSPD, alt_km × 10) # 10 m/s per km AGL
This exits braking earlier when altitude is limited (e.g., at 5 km AGL, accepts up to 50 m/s h_speed). The target-retrograde controller used in vertical descent is the same function as in braking, so it seamlessly continues managing both lateral correction and descent rate with more altitude margin available.
Deceleration budget scaling: The DESCENT_DECEL_BUDGET used for descent rate target calculations is scaled to the ship’s actual deceleration margin on the current body. This ensures the descent rate profile is achievable regardless of local gravity:
decel_budget = min(DESCENT_DECEL_BUDGET, decel_margin * 0.5)
The 0.5 factor provides safety margin for attitude errors and drag perturbations.
ZEM/ZEV Descent Phase (#1016)
The descent phase uses the D’Souza ZEM/ZEV (Zero-Effort-Miss / Zero-Effort-Velocity) guidance law for targeted landings. This replaces the braking+vertical descent two-phase approach with a single unified controller that simultaneously steers toward the pad and brakes.
Guidance law (AIAA-97-0640):
tgo = kinematic time-to-go (quadratic from closing speed + net decel)
a_pred = g + descent_bias (total predicted uncontrolled acceleration)
ZEM = r_rel + v_rel * tgo + 0.5 * a_pred * tgo²
ZEV = v_rel + a_pred * tgo
a_cmd = -6·ZEM/tgo² + 2·ZEV/tgo + descent_bias (commanded acceleration)
The prediction terms (a_pred) include both gravity and the high-AGL descent bias so that lateral corrections account for the actual descent trajectory. The bias is added to a_cmd because it’s engine thrust, not free acceleration.
The +2·ZEV sign produces approach-then-brake behavior: at long range ZEM dominates (steer toward pad), at short range ZEV dominates (brake).
High-AGL descent bias: At orbital altitude, closing velocity along the range vector is near-zero (velocity is tangential), so tgo is huge and ZEM/ZEV corrections are tiny. A radially-inward bias proportional to g_local × altitude_deficit forces the ship to commit to descending. Active above 10 km AGL, full strength above 50 km. Applied to a_cmd only, NOT to ZEM/ZEV prediction terms (including it in prediction overcorrects laterally because the bias fades as descent rate increases).
Descent → terminal transition: Conditional handoff when ZEM/ZEV has converged the ship close to the pad:
- AGL < 5 km AND pad distance < 50% of AGL (ship is nearly above the pad), OR
- AGL < 1 km (safety floor — prevents low-AGL limiter hover trap regardless of pad distance)
Thrust clamping: thrust_level = min(1.0, |a_cmd| / thrust_accel). When the engine saturates at 1.0, the guidance direction is preserved but magnitude is limited.
Vertical Safety Override (#1021)
When thrust is saturated and lateral correction consumes thrust budget, insufficient vertical braking can cause crashes. A safety override detects this:
net_decel = thrust_accel - g_local (available decel above hover)
stopping_dist = v_down² / (2 * net_decel)
if stopping_dist > 0.7 * AGL and v_down > 10:
override to radial-outward at full thrust
This fires only when impact is imminent (stopping distance > 70% of AGL and descending > 10 m/s). The override points the engine radially outward (pure vertical brake) at full thrust, temporarily abandoning lateral correction. ZEM/ZEV resumes automatically on the next tick once the safety condition clears.
Dependencies
- #744 Server-side terrain height (done)
- #755 Planetary lander ship class (done)